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.
490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import type { ChatMessage, RoomInfo } from '@/api/hermes/group-chat'
|
|
|
|
const groupChatApiMock = vi.hoisted(() => {
|
|
const handlers = new Map<string, Function[]>()
|
|
const socket: any = {
|
|
connected: true,
|
|
id: 'socket-1',
|
|
on: vi.fn((event: string, cb: Function) => {
|
|
const existing = handlers.get(event) || []
|
|
existing.push(cb)
|
|
handlers.set(event, existing)
|
|
return socket
|
|
}),
|
|
emit: vi.fn((event: string, _data?: unknown, ack?: Function) => {
|
|
if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] })
|
|
return socket
|
|
}),
|
|
disconnect: vi.fn(),
|
|
}
|
|
return {
|
|
handlers,
|
|
socket,
|
|
connectGroupChat: vi.fn(() => socket),
|
|
disconnectGroupChat: vi.fn(),
|
|
getSocket: vi.fn(() => socket),
|
|
getStoredUserId: vi.fn(() => 'user-1'),
|
|
getStoredUserName: vi.fn(() => 'tester'),
|
|
createRoom: vi.fn(),
|
|
listRooms: vi.fn(),
|
|
getRoomDetail: vi.fn(),
|
|
joinRoomByCode: vi.fn(),
|
|
addAgent: vi.fn(),
|
|
listAgents: vi.fn(),
|
|
removeAgent: vi.fn(),
|
|
cloneRoom: vi.fn(),
|
|
deleteRoom: vi.fn(),
|
|
clearRoomContext: vi.fn(),
|
|
}
|
|
})
|
|
const clientApiMock = vi.hoisted(() => ({
|
|
getApiKey: vi.fn(() => 'test-token'),
|
|
getActiveProfileName: vi.fn(() => 'research'),
|
|
getStoredUsername: vi.fn(() => null),
|
|
}))
|
|
const authApiMock = vi.hoisted(() => ({
|
|
fetchCurrentUser: vi.fn(),
|
|
}))
|
|
const fetchMock = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('@/api/hermes/group-chat', () => groupChatApiMock)
|
|
vi.mock('@/api/client', () => clientApiMock)
|
|
vi.mock('@/api/auth', () => authApiMock)
|
|
vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: vi.fn((path: string) => `/download?path=${path}`) }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
function emitSocket(event: string, payload: unknown) {
|
|
for (const cb of groupChatApiMock.handlers.get(event) || []) cb(payload)
|
|
}
|
|
|
|
const room: RoomInfo = {
|
|
id: 'room-1',
|
|
name: 'Test Room',
|
|
inviteCode: 'ROOM1',
|
|
workspace: '',
|
|
}
|
|
|
|
function assistantMessage(overrides: Partial<ChatMessage>): ChatMessage {
|
|
return {
|
|
id: 'msg-1',
|
|
roomId: 'room-1',
|
|
senderId: 'agent-1',
|
|
senderName: 'bot',
|
|
content: '',
|
|
timestamp: 1,
|
|
role: 'assistant',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
async function createJoinedStore(initialMessages: ChatMessage[] = []) {
|
|
groupChatApiMock.getRoomDetail.mockResolvedValue({
|
|
room,
|
|
messages: initialMessages,
|
|
agents: [],
|
|
members: [],
|
|
})
|
|
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
|
|
const store = useGroupChatStore()
|
|
store.connect()
|
|
await store.joinRoom('room-1')
|
|
groupChatApiMock.getRoomDetail.mockClear()
|
|
return store
|
|
}
|
|
|
|
describe('group chat store streaming merge', () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers()
|
|
setActivePinia(createPinia())
|
|
groupChatApiMock.handlers.clear()
|
|
for (const key of Object.keys(groupChatApiMock)) {
|
|
const value = (groupChatApiMock as any)[key]
|
|
if (value?.mockReset && key !== 'socket') value.mockReset()
|
|
}
|
|
groupChatApiMock.connectGroupChat.mockReturnValue(groupChatApiMock.socket)
|
|
groupChatApiMock.getSocket.mockReturnValue(groupChatApiMock.socket)
|
|
groupChatApiMock.getStoredUserId.mockReturnValue('user-1')
|
|
groupChatApiMock.getStoredUserName.mockReturnValue('tester')
|
|
clientApiMock.getApiKey.mockReturnValue('test-token')
|
|
clientApiMock.getActiveProfileName.mockReturnValue('research')
|
|
clientApiMock.getStoredUsername.mockReturnValue(null)
|
|
authApiMock.fetchCurrentUser.mockRejectedValue(new Error('not signed in'))
|
|
fetchMock.mockReset()
|
|
groupChatApiMock.socket.on.mockClear()
|
|
groupChatApiMock.socket.emit.mockReset()
|
|
groupChatApiMock.socket.emit.mockImplementation((event: string, _data?: unknown, ack?: Function) => {
|
|
if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] })
|
|
return groupChatApiMock.socket
|
|
})
|
|
groupChatApiMock.socket.disconnect.mockClear()
|
|
})
|
|
|
|
it('preserves streamed reasoning when the final message supplies content only', async () => {
|
|
const store = await createJoinedStore()
|
|
|
|
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
|
|
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' })
|
|
emitSocket('message', assistantMessage({ id: 'msg-1', content: '收到', reasoning: null, reasoning_content: null }))
|
|
|
|
expect(store.messages).toHaveLength(1)
|
|
expect(store.messages[0]).toMatchObject({
|
|
id: 'msg-1',
|
|
content: '收到',
|
|
reasoning: 'thinking...',
|
|
reasoning_content: 'thinking...',
|
|
isStreaming: false,
|
|
})
|
|
})
|
|
|
|
it('preserves streamed content when the final message payload is blank', async () => {
|
|
const store = await createJoinedStore()
|
|
|
|
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
|
|
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: 'final' })
|
|
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' answer' })
|
|
emitSocket('message', assistantMessage({ id: 'msg-1', content: '', reasoning: 'thinking...' }))
|
|
|
|
expect(store.messages).toHaveLength(1)
|
|
expect(store.messages[0]).toMatchObject({
|
|
id: 'msg-1',
|
|
content: 'final answer',
|
|
reasoning: 'thinking...',
|
|
isStreaming: false,
|
|
})
|
|
})
|
|
|
|
it('ignores late content deltas for a completed message', async () => {
|
|
const store = await createJoinedStore()
|
|
|
|
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
|
|
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
|
|
|
|
expect(store.messages).toHaveLength(1)
|
|
expect(store.messages[0]).toMatchObject({
|
|
id: 'msg-1',
|
|
content: 'final answer',
|
|
reasoning: 'thinking...',
|
|
isStreaming: false,
|
|
})
|
|
})
|
|
|
|
it('ignores late reasoning deltas for a completed message', async () => {
|
|
const store = await createJoinedStore()
|
|
|
|
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
|
|
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
|
|
|
|
expect(store.messages).toHaveLength(1)
|
|
expect(store.messages[0]).toMatchObject({
|
|
id: 'msg-1',
|
|
content: 'final answer',
|
|
reasoning: 'thinking...',
|
|
isStreaming: false,
|
|
})
|
|
})
|
|
|
|
it('preserves stable message order via sortedMessages when agents finish out of sequence', async () => {
|
|
const store = await createJoinedStore()
|
|
|
|
// Agent A starts first
|
|
emitSocket('message_stream_start', assistantMessage({ id: 'msg-a', senderId: 'agent-a', senderName: 'Agent A', timestamp: 100 }))
|
|
// Agent B starts second
|
|
emitSocket('message_stream_start', assistantMessage({ id: 'msg-b', senderId: 'agent-b', senderName: 'Agent B', timestamp: 200 }))
|
|
|
|
// Agent B finishes first (completes with a later timestamp)
|
|
emitSocket('message', assistantMessage({ id: 'msg-b', senderId: 'agent-b', senderName: 'Agent B', content: 'fast reply', timestamp: 300 }))
|
|
// Agent A finishes last (but started first)
|
|
emitSocket('message', assistantMessage({ id: 'msg-a', senderId: 'agent-a', senderName: 'Agent A', content: 'slow reply', timestamp: 400 }))
|
|
|
|
// sortedMessages should keep A (firstSeenAt=100) before B (firstSeenAt=200)
|
|
expect(store.sortedMessages).toHaveLength(2)
|
|
expect(store.sortedMessages[0].id).toBe('msg-a')
|
|
expect(store.sortedMessages[0].content).toBe('slow reply')
|
|
expect(store.sortedMessages[1].id).toBe('msg-b')
|
|
expect(store.sortedMessages[1].content).toBe('fast reply')
|
|
})
|
|
|
|
it('ignores a late empty stream start for a completed message', async () => {
|
|
const store = await createJoinedStore()
|
|
|
|
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
|
|
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 }))
|
|
|
|
expect(store.messages).toHaveLength(1)
|
|
expect(store.messages[0]).toMatchObject({
|
|
id: 'msg-1',
|
|
content: 'final answer',
|
|
reasoning: 'thinking...',
|
|
isStreaming: false,
|
|
})
|
|
})
|
|
|
|
it('ignores a late stream start for a completed empty tool-call message', async () => {
|
|
const store = await createJoinedStore()
|
|
const toolCalls = [{ id: 'tool-1', type: 'function', function: { name: 'lookup', arguments: '{}' } }]
|
|
|
|
emitSocket('message', assistantMessage({ id: 'msg-1', content: '', tool_calls: toolCalls }))
|
|
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 }))
|
|
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
|
|
|
|
expect(store.messages).toHaveLength(1)
|
|
expect(store.messages[0]).toMatchObject({
|
|
id: 'msg-1',
|
|
content: '',
|
|
tool_calls: toolCalls,
|
|
isStreaming: false,
|
|
})
|
|
})
|
|
|
|
it('refetches room detail when a stream ends with reasoning but no final content', async () => {
|
|
vi.useFakeTimers()
|
|
const store = await createJoinedStore()
|
|
groupChatApiMock.getRoomDetail.mockResolvedValue({
|
|
room,
|
|
agents: [],
|
|
members: [],
|
|
messages: [assistantMessage({ id: 'msg-1', content: 'final from db', reasoning: 'thinking...' })],
|
|
})
|
|
|
|
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
|
|
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' })
|
|
emitSocket('message_stream_end', { roomId: 'room-1', id: 'msg-1' })
|
|
|
|
await vi.runAllTimersAsync()
|
|
|
|
expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1')
|
|
expect(store.messages[0]).toMatchObject({
|
|
id: 'msg-1',
|
|
content: 'final from db',
|
|
reasoning: 'thinking...',
|
|
isStreaming: false,
|
|
})
|
|
})
|
|
|
|
it('maps non-string and falsy tool payloads from room history', async () => {
|
|
const store = await createJoinedStore([
|
|
assistantMessage({
|
|
id: 'msg-tool-call',
|
|
content: '',
|
|
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'lookup', arguments: false } }],
|
|
} as unknown as Partial<ChatMessage>),
|
|
assistantMessage({
|
|
id: 'msg-tool-result',
|
|
role: 'tool',
|
|
tool_call_id: 'call-1',
|
|
content: { ok: true },
|
|
} as unknown as Partial<ChatMessage>),
|
|
])
|
|
|
|
expect(store.sortedMessages).toHaveLength(1)
|
|
expect(store.sortedMessages[0]).toMatchObject({
|
|
role: 'tool',
|
|
toolName: 'lookup',
|
|
toolArgs: false,
|
|
toolResult: { ok: true },
|
|
toolStatus: 'done',
|
|
})
|
|
})
|
|
|
|
it('rejoins the active room after socket reconnect and restores transient room state', async () => {
|
|
const store = await createJoinedStore()
|
|
store.loadedMessageCount = 300
|
|
store.totalMessages = 500
|
|
store.hasMoreBefore = true
|
|
store.rooms = [room]
|
|
groupChatApiMock.socket.emit.mockClear()
|
|
groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => {
|
|
if (event === 'join' && ack) {
|
|
ack({
|
|
members: [{ id: 'human-1', name: 'Human', online: true }],
|
|
agents: [{ id: 'agent-row-1', agentId: 'agent-1', profile: 'worker', name: 'Worker' }],
|
|
rooms: ['room-1', 'room-2'],
|
|
messages: [assistantMessage({ id: 'missed-1', content: 'missed while offline', timestamp: 2 })],
|
|
typingUsers: [{ userId: 'agent-1', userName: 'Worker' }],
|
|
contextStatuses: [{ agentName: 'Worker', status: 'replying' }],
|
|
})
|
|
}
|
|
return groupChatApiMock.socket
|
|
})
|
|
|
|
emitSocket('connect', undefined)
|
|
await Promise.resolve()
|
|
|
|
expect(groupChatApiMock.socket.emit).toHaveBeenCalledWith(
|
|
'join',
|
|
expect.objectContaining({ roomId: 'room-1', name: 'tester' }),
|
|
expect.any(Function),
|
|
)
|
|
expect(store.members).toEqual([expect.objectContaining({ id: 'human-1', name: 'Human' })])
|
|
expect(store.agents).toEqual([expect.objectContaining({ profile: 'worker', name: 'Worker' })])
|
|
expect(store.rooms).toEqual([room])
|
|
expect(store.messages).toEqual([expect.objectContaining({ id: 'missed-1', content: 'missed while offline' })])
|
|
expect(store.loadedMessageCount).toBe(300)
|
|
expect(store.totalMessages).toBe(500)
|
|
expect(store.hasMoreBefore).toBe(true)
|
|
expect(store.typingNames).toEqual(['Worker'])
|
|
expect(store.contextStatus).toEqual(expect.objectContaining({ agentName: 'Worker', status: 'replying' }))
|
|
})
|
|
|
|
it('loads group history in 150-message pages and stops at the 600-message display cap', async () => {
|
|
const store = await createJoinedStore()
|
|
store.loadedMessageCount = 450
|
|
store.totalMessages = 700
|
|
store.hasMoreBefore = true
|
|
const olderMessages = Array.from({ length: 150 }, (_, index) =>
|
|
assistantMessage({ id: `older-${index}`, timestamp: index + 1, content: `older ${index}` }),
|
|
)
|
|
groupChatApiMock.getRoomDetail.mockResolvedValueOnce({
|
|
room,
|
|
messages: olderMessages,
|
|
agents: [],
|
|
members: [],
|
|
total: 700,
|
|
offset: 450,
|
|
limit: 150,
|
|
hasMore: true,
|
|
})
|
|
|
|
await expect(store.loadOlderMessages()).resolves.toBe(true)
|
|
|
|
expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1', { offset: 450, limit: 150 })
|
|
expect(store.loadedMessageCount).toBe(600)
|
|
expect(store.hasMoreBefore).toBe(true)
|
|
expect(store.hasReachedMessageDisplayLimit).toBe(true)
|
|
|
|
groupChatApiMock.getRoomDetail.mockClear()
|
|
await expect(store.loadOlderMessages()).resolves.toBe(false)
|
|
expect(groupChatApiMock.getRoomDetail).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('ignores a stale reconnect join ack after the user switches rooms', async () => {
|
|
const store = await createJoinedStore()
|
|
let joinAck: Function | undefined
|
|
groupChatApiMock.socket.emit.mockClear()
|
|
groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => {
|
|
if (event === 'join') joinAck = ack
|
|
return groupChatApiMock.socket
|
|
})
|
|
|
|
emitSocket('connect', undefined)
|
|
await Promise.resolve()
|
|
expect(joinAck).toBeDefined()
|
|
|
|
const roomTwoMessage = assistantMessage({ id: 'room-2-msg', roomId: 'room-2', content: 'current room', timestamp: 3 })
|
|
store.currentRoomId = 'room-2'
|
|
store.members = [{ id: 'human-2', name: 'Room 2 Human', online: true }]
|
|
store.messages = [roomTwoMessage]
|
|
|
|
joinAck?.({
|
|
members: [{ id: 'human-1', name: 'Old Room Human', online: true }],
|
|
messages: [assistantMessage({ id: 'old-room-msg', content: 'old room', timestamp: 2 })],
|
|
typingUsers: [{ userId: 'agent-1', userName: 'Worker' }],
|
|
contextStatuses: [{ agentName: 'Worker', status: 'replying' }],
|
|
})
|
|
await Promise.resolve()
|
|
|
|
expect(store.currentRoomId).toBe('room-2')
|
|
expect(store.members).toEqual([expect.objectContaining({ id: 'human-2', name: 'Room 2 Human' })])
|
|
expect(store.messages).toEqual([roomTwoMessage])
|
|
expect(store.typingNames).toEqual([])
|
|
})
|
|
|
|
it('does not rejoin when socket connects without an active room', async () => {
|
|
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
|
|
const store = useGroupChatStore()
|
|
await store.connect()
|
|
groupChatApiMock.socket.emit.mockClear()
|
|
|
|
emitSocket('connect', undefined)
|
|
await Promise.resolve()
|
|
|
|
expect(groupChatApiMock.socket.emit).not.toHaveBeenCalledWith(
|
|
'join',
|
|
expect.anything(),
|
|
expect.any(Function),
|
|
)
|
|
})
|
|
|
|
it('uses authenticated account identity and restores the persisted room member name', async () => {
|
|
groupChatApiMock.getStoredUserId.mockReturnValue('browser-local-id')
|
|
groupChatApiMock.getStoredUserName.mockReturnValue(null)
|
|
clientApiMock.getStoredUsername.mockReturnValue('alice-login')
|
|
authApiMock.fetchCurrentUser.mockResolvedValue({
|
|
id: 42,
|
|
username: 'alice-login',
|
|
role: 'admin',
|
|
status: 'active',
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
last_login_at: null,
|
|
avatar: '',
|
|
})
|
|
groupChatApiMock.getRoomDetail.mockResolvedValue({
|
|
room,
|
|
messages: [],
|
|
agents: [],
|
|
members: [],
|
|
})
|
|
groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => {
|
|
if (event === 'join' && ack) {
|
|
expect(data).toMatchObject({ roomId: 'room-1' })
|
|
expect(data.name).toBeUndefined()
|
|
ack({
|
|
members: [{ id: 'member-1', userId: 'auth:42', name: 'Alice Display', description: '', joinedAt: 1 }],
|
|
agents: [],
|
|
typingUsers: [],
|
|
contextStatuses: [],
|
|
})
|
|
}
|
|
return groupChatApiMock.socket
|
|
})
|
|
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
|
|
const store = useGroupChatStore()
|
|
|
|
await store.connect()
|
|
await store.joinRoom('room-1')
|
|
|
|
expect(groupChatApiMock.connectGroupChat).toHaveBeenCalledWith({
|
|
userId: 'auth:42',
|
|
userName: undefined,
|
|
authUserId: 42,
|
|
})
|
|
expect(store.userId).toBe('auth:42')
|
|
expect(store.userName).toBe('Alice Display')
|
|
})
|
|
|
|
it('adds auth and active profile headers to group chat uploads', async () => {
|
|
const store = await createJoinedStore()
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ files: [{ name: 'note.txt', path: '/tmp/note.txt' }] }),
|
|
})
|
|
groupChatApiMock.socket.emit.mockImplementation((event: string, _data?: unknown, ack?: Function) => {
|
|
if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] })
|
|
if (event === 'message' && ack) ack({ id: 'msg-server' })
|
|
return groupChatApiMock.socket
|
|
})
|
|
|
|
await store.sendMessage('hello', [{
|
|
id: 'file-1',
|
|
name: 'note.txt',
|
|
type: 'text/plain',
|
|
size: 5,
|
|
url: '',
|
|
file: new File(['hello'], 'note.txt', { type: 'text/plain' }),
|
|
}])
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
const [url, options] = fetchMock.mock.calls[0]
|
|
expect(url).toBe('/upload')
|
|
expect(options.method).toBe('POST')
|
|
expect(options.headers.Authorization).toBe('Bearer test-token')
|
|
expect(options.headers['X-Hermes-Profile']).toBe('research')
|
|
expect(options.body).toBeInstanceOf(FormData)
|
|
})
|
|
})
|