Files
hermes-web-ui/tests/client/group-chat-workspace-diff.test.ts
2026-07-20 09:42:10 +08:00

327 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import GroupMessageItem from '@/components/hermes/group-chat/GroupMessageItem.vue'
import GroupMessageList from '@/components/hermes/group-chat/GroupMessageList.vue'
import type { ChatMessage, GroupWorkspaceDiffPayload } from '@/api/hermes/group-chat'
const toolTraceVisibleState = vi.hoisted(() => ({ value: true }))
const groupChatApiMock = vi.hoisted(() => {
const socket: any = {
id: 'socket-1',
connected: true,
on: vi.fn(() => socket),
once: vi.fn(() => socket),
off: vi.fn(() => socket),
emit: vi.fn((event: string, _payload: unknown, ack?: Function) => {
if (event === 'join') {
ack?.({
roomId: 'room-1',
roomName: 'Room 1',
messages: [],
agents: [],
members: [],
typingUsers: [],
contextStatuses: [],
})
}
return socket
}),
}
return {
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(),
updateInviteCode: vi.fn(),
updateRoomWorkspace: vi.fn(),
}
})
vi.mock('@/api/hermes/group-chat', () => groupChatApiMock)
vi.mock('@/api/client', () => ({
getApiKey: vi.fn(() => 'token'),
getActiveProfileName: vi.fn(() => 'default'),
getStoredUsername: vi.fn(() => null),
}))
vi.mock('@/api/auth', () => ({ fetchCurrentUser: vi.fn(async () => { throw new Error('no user') }) }))
vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: vi.fn((path: string) => `/download?path=${path}`) }))
vi.mock('@/composables/useToolTraceVisibility', () => ({
useToolTraceVisibility: () => ({ toolTraceVisible: toolTraceVisibleState, toggleToolTraceVisible: vi.fn() }),
}))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
vi.mock('naive-ui', () => ({
useMessage: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }),
}))
const payload: GroupWorkspaceDiffPayload = {
kind: 'workspace_diff',
version: 1,
room_id: 'room-1',
session_id: 'session-1',
run_id: '0123456789abcdef0123456789abcdef',
status: 'completed',
change_id: 'change-1',
workspace_basename: 'repo',
workspace: '/tmp/repo',
files_changed: 2,
additions: 3,
deletions: 1,
truncated: false,
files: [
{ id: 1, path: 'src/a.ts', change_type: 'modified', additions: 2, deletions: 1, patch: 'diff --git a/src/a.ts b/src/a.ts\n-old\n+new\n', binary: false, truncated: false },
{ id: 2, path: 'asset.bin', change_type: 'added', additions: 0, deletions: 0, patch: null, binary: true, truncated: false },
],
}
function assistantMessage(): ChatMessage {
return {
id: 'assistant-1',
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'Worker',
content: 'Finished the workspace update.',
timestamp: 1,
role: 'assistant',
}
}
function workspaceDiffMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: 'diff-1',
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'Worker',
content: JSON.stringify(payload),
timestamp: 1,
role: 'tool',
tool_call_id: `workspace_diff:${payload.run_id}`,
tool_name: 'workspace_diff',
...overrides,
}
}
describe('group chat workspace diff client rendering', () => {
beforeEach(() => {
setActivePinia(createPinia())
toolTraceVisibleState.value = true
vi.clearAllMocks()
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: { addEventListener: vi.fn(), removeEventListener: vi.fn(), getVoices: vi.fn(() => []), speak: vi.fn(), cancel: vi.fn(), pause: vi.fn(), resume: vi.fn() },
})
})
it('maps persisted workspace_diff tool JSON to a structured tool result', async () => {
groupChatApiMock.getRoomDetail.mockResolvedValue({
room: { id: 'room-1', name: 'Room 1', inviteCode: null, workspace: '/tmp/repo' },
messages: [workspaceDiffMessage()],
agents: [],
members: [],
})
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
const store = useGroupChatStore()
await store.joinRoom('room-1')
expect(store.sortedMessages[0]).toMatchObject({
role: 'tool',
toolName: 'workspace_diff',
toolResult: expect.objectContaining({ kind: 'workspace_diff', files_changed: 2 }),
})
})
it('attaches persisted and realtime workspace diffs to their exact assistant message', async () => {
groupChatApiMock.getRoomDetail.mockResolvedValue({
room: { id: 'room-1', name: 'Room 1', inviteCode: null, workspace: '/tmp/repo' },
messages: [
assistantMessage(),
workspaceDiffMessage({
content: JSON.stringify({ ...payload, parent_message_id: 'assistant-1' }),
timestamp: 2,
}),
],
agents: [],
members: [],
})
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
const store = useGroupChatStore()
await store.joinRoom('room-1')
expect(store.sortedMessages).toHaveLength(1)
expect(store.sortedMessages[0]).toMatchObject({
id: 'assistant-1',
role: 'assistant',
workspaceChanges: [expect.objectContaining({ change_id: 'change-1' })],
})
store.messages = [assistantMessage()]
store.messages.push(workspaceDiffMessage({
content: JSON.stringify({ ...payload, parent_message_id: 'assistant-1' }),
timestamp: 2,
}))
expect(store.sortedMessages).toHaveLength(1)
expect(store.sortedMessages[0].workspaceChanges?.[0]?.change_id).toBe('change-1')
})
it('renders an associated workspace diff inside the assistant message', async () => {
const wrapper = mount(GroupMessageItem, {
props: {
message: {
...assistantMessage(),
workspaceChanges: [{ ...payload, parent_message_id: 'assistant-1' }],
},
agents: [{ id: 'a1', roomId: 'room-1', agentId: 'agent-1', profile: 'default', name: 'Worker', description: '', invited: 0 }],
members: [],
currentUserId: 'user-1',
},
global: { stubs: { MarkdownRenderer: true, ProfileAvatar: true } },
})
expect(wrapper.find('.tool-message').exists()).toBe(false)
expect(wrapper.find('.assistant-workspace-change').exists()).toBe(true)
expect(wrapper.text()).toContain('chat.changesThisTurn')
await wrapper.find('.tool-change-card-header').trigger('click')
expect(wrapper.find('.tool-change-file-row').text()).toContain('a.ts')
})
it('renders a workspace diff card collapsed by default and expands it on demand', async () => {
const wrapper = mount(GroupMessageItem, {
props: {
message: {
...workspaceDiffMessage(),
toolName: 'workspace_diff',
toolResult: payload,
toolStatus: 'done',
},
agents: [],
members: [],
currentUserId: 'user-1',
},
global: { stubs: { MarkdownRenderer: true, ProfileAvatar: true } },
})
expect(wrapper.find('.tool-change-card').exists()).toBe(true)
expect(wrapper.find('.tool-change-files').exists()).toBe(false)
expect(wrapper.find('.tool-change-card-header').attributes('aria-expanded')).toBe('false')
await wrapper.find('.tool-change-card-header').trigger('click')
expect(wrapper.find('.tool-change-card-header').attributes('aria-expanded')).toBe('true')
expect(wrapper.find('.tool-change-file-row').text()).toContain('a.ts')
expect(wrapper.find('.tool-line').exists()).toBe(false)
expect(wrapper.text()).not.toContain('"kind"')
await wrapper.find('.tool-change-file-row').trigger('click')
const { useToolPanelStore } = await import('@/stores/hermes/tool-panel')
expect(useToolPanelStore().workspaceDiff).toMatchObject({
patch: expect.stringContaining('+new'),
editable: false,
file: expect.objectContaining({ path: 'src/a.ts' }),
})
})
it('keeps workspace diff audit cards visible when generic tool traces are hidden', async () => {
toolTraceVisibleState.value = false
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
const store = useGroupChatStore()
store.currentRoomId = 'room-1'
store.messages = [
{
...workspaceDiffMessage(),
toolName: 'workspace_diff',
toolResult: payload,
toolStatus: 'done',
},
{
id: 'tool-1',
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'Worker',
content: '{}',
timestamp: 2,
role: 'tool',
tool_name: 'shell',
toolName: 'shell',
toolStatus: 'done',
} as ChatMessage,
]
const wrapper = mount(GroupMessageList, {
global: {
stubs: {
GroupMessageItem: true,
VirtualMessageList: {
name: 'VirtualMessageList',
props: ['messages'],
methods: {
scrollToBottom() {},
isNearBottom() { return true },
captureScrollPosition() { return null },
restoreScrollPosition() {},
},
template: '<div><slot v-for="message in messages" name="item" :message="message" /></div>',
},
},
},
})
const messages = wrapper.getComponent({ name: 'VirtualMessageList' }).props('messages') as ChatMessage[]
expect(messages.map(message => message.id)).toEqual(['diff-1'])
})
it('hands previewable group attachments to the shared file panel instead of downloading', async () => {
const previewRequests: Array<{ path: string; fileName: string }> = []
const handlePreview = (event: Event) => {
const customEvent = event as CustomEvent<{ path: string; fileName: string }>
previewRequests.push(customEvent.detail)
customEvent.preventDefault()
}
window.addEventListener('hermes:preview-workspace-file', handlePreview)
const wrapper = mount(GroupMessageItem, {
props: {
message: {
id: 'file-1',
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'Worker',
content: JSON.stringify([{ type: 'file', name: 'sales-data.xlsx', path: '/tmp/repo/sales-data.xlsx' }]),
timestamp: 1,
role: 'assistant',
},
agents: [{ id: 'a1', roomId: 'room-1', agentId: 'agent-1', profile: 'default', name: 'Worker', description: '', invited: 0 }],
members: [],
currentUserId: 'user-1',
},
global: { stubs: { MarkdownRenderer: true, ProfileAvatar: true } },
})
try {
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
wrapper.get('.msg-attachment-file').element.dispatchEvent(click)
expect(click.defaultPrevented).toBe(true)
expect(previewRequests).toEqual([{ path: '/tmp/repo/sales-data.xlsx', fileName: 'sales-data.xlsx' }])
} finally {
wrapper.unmount()
window.removeEventListener('hermes:preview-workspace-file', handlePreview)
}
})
})