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

343 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { createServer, type Server as HttpServer } from 'http'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const dbState = vi.hoisted(() => ({
db: null as DatabaseSync | null,
}))
vi.mock('../../packages/server/src/db/index', () => ({
getDb: () => dbState.db,
isSqliteAvailable: () => Boolean(dbState.db),
}))
vi.mock('socket.io-client', () => ({
io: vi.fn(() => ({
id: 'agent-socket',
connected: true,
io: { on: vi.fn() },
on: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
})),
}))
vi.mock('../../packages/server/src/services/auth', () => ({
getToken: vi.fn(async () => 'test-token'),
}))
describe('group chat workspace diff persistence', () => {
let root: string
let workspace: string
let httpServer: HttpServer
beforeEach(async () => {
vi.resetModules()
root = mkdtempSync(join(tmpdir(), 'hermes-gc-diff-'))
workspace = join(root, 'workspace')
mkdirSync(workspace)
dbState.db = new DatabaseSync(':memory:')
const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas')
initAllHermesTables()
httpServer = createServer()
})
afterEach(() => {
httpServer?.close()
dbState.db?.close()
dbState.db = null
rmSync(root, { recursive: true, force: true })
})
async function makeDraft(runId = '0123456789abcdef0123456789abcdef') {
const tracker = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
writeFileSync(join(workspace, 'file.txt'), 'old\n')
tracker.startWorkspaceRunCheckpoint({ sessionId: 'session-1', runId, workspace })
writeFileSync(join(workspace, 'file.txt'), 'new\n')
return tracker.completeWorkspaceRunCheckpointDraft({ sessionId: 'session-1', runId, workspace })
}
async function saveDiff(storage: any, roomId = 'room-1', runId = '0123456789abcdef0123456789abcdef') {
const draft = await makeDraft(runId)
const saved = storage.saveWorkspaceDiffMessageForRun({
roomId,
senderId: 'agent-1',
senderName: 'Worker',
sessionId: 'session-1',
runId,
status: 'completed',
workspace,
draft: draft!,
})
return { saved, payload: JSON.parse(saved!.message.content) }
}
function countRows(table: string, where = '', ...args: unknown[]): number {
return (dbState.db?.prepare(`SELECT COUNT(*) AS count FROM ${table}${where}`).get(...args) as { count: number }).count
}
function expectWorkspaceChangeDeleted(changeId: string) {
expect(countRows('workspace_run_changes', ' WHERE change_id = ?', changeId)).toBe(0)
expect(countRows('workspace_run_change_files', ' WHERE change_id = ?', changeId)).toBe(0)
}
it('persists one workspace_run_change and one durable workspace_diff group message', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
const runId = '0123456789abcdef0123456789abcdef'
const draft = await makeDraft(runId)
const saved = storage.saveWorkspaceDiffMessageForRun({
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'Worker',
sessionId: 'session-1',
runId,
status: 'completed',
workspace,
draft: draft!,
})
expect(saved?.message).toMatchObject({
role: 'tool',
tool_name: 'workspace_diff',
tool_call_id: `workspace_diff:${runId}`,
senderId: 'agent-1',
senderName: 'Worker',
})
const payload = JSON.parse(saved!.message.content)
expect(payload).toMatchObject({
kind: 'workspace_diff',
version: 1,
room_id: 'room-1',
session_id: 'session-1',
run_id: runId,
status: 'completed',
files_changed: 1,
})
expect(payload.files[0].patch).toContain('-old')
expect(payload.files[0].patch).toContain('+new')
expect(payload.workspace).toBeUndefined()
expect(payload.workspace_basename).toBe('workspace')
const rows = dbState.db?.prepare('SELECT COUNT(*) AS count FROM gc_messages WHERE tool_name = ?').get('workspace_diff') as { count: number }
expect(rows.count).toBe(1)
const changeRow = dbState.db?.prepare('SELECT workspace, room_id, message_id FROM workspace_run_changes WHERE change_id = ?').get(payload.change_id) as { workspace: string; room_id: string; message_id: string }
expect(changeRow.workspace).toBe('workspace')
expect(changeRow.workspace).not.toBe(workspace)
expect(changeRow.room_id).toBe('room-1')
expect(changeRow.message_id).toBe(saved!.message.id)
const changeRows = dbState.db?.prepare('SELECT COUNT(*) AS count FROM workspace_run_changes WHERE change_id = ?').get(payload.change_id) as { count: number }
expect(changeRows.count).toBe(1)
server.getIO().close()
})
it('keeps workspace_diff message ids unique when room ids are long', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
const roomId = `room-${'x'.repeat(220)}`
storage.saveRoom(roomId, 'Long Room')
const runA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const runB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
const draftA = await makeDraft(runA)
const savedA = storage.saveWorkspaceDiffMessageForRun({
roomId,
senderId: 'agent-1',
senderName: 'Worker',
sessionId: 'session-1',
runId: runA,
status: 'completed',
workspace,
draft: draftA!,
})
const draftB = await makeDraft(runB)
const savedB = storage.saveWorkspaceDiffMessageForRun({
roomId,
senderId: 'agent-1',
senderName: 'Worker',
sessionId: 'session-1',
runId: runB,
status: 'completed',
workspace,
draft: draftB!,
})
expect(savedA?.message.id).not.toBe(savedB?.message.id)
expect(savedA?.message.id).toContain(runA)
expect(savedB?.message.id).toContain(runB)
const rows = dbState.db?.prepare('SELECT COUNT(*) AS count FROM gc_messages WHERE roomId = ? AND tool_name = ?').get(roomId, 'workspace_diff') as { count: number }
expect(rows.count).toBe(2)
server.getIO().close()
})
it('cleans persisted workspace diffs when room context is cleared', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
const { payload } = await saveDiff(storage)
expect(countRows('workspace_run_changes', ' WHERE change_id = ?', payload.change_id)).toBe(1)
storage.clearRoomContext('room-1')
expectWorkspaceChangeDeleted(payload.change_id)
expect(countRows('gc_messages', ' WHERE roomId = ?', 'room-1')).toBe(0)
server.getIO().close()
})
it('does not delete unrelated workspace changes from spoofed workspace_diff message content', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
storage.saveRoom('room-2', 'Room 2')
const { payload } = await saveDiff(storage, 'room-1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
storage.saveMessageAndRefreshRoom({
id: 'fake-workspace-diff',
roomId: 'room-2',
senderId: 'user-2',
senderName: 'Mallory',
content: JSON.stringify({ kind: 'workspace_diff', change_id: payload.change_id }),
timestamp: 1,
role: 'tool',
tool_name: 'workspace_diff',
tool_call_id: 'workspace_diff:fake',
})
storage.clearRoomContext('room-2')
expect(countRows('workspace_run_changes', ' WHERE change_id = ?', payload.change_id)).toBe(1)
expect(countRows('workspace_run_change_files', ' WHERE change_id = ?', payload.change_id)).toBeGreaterThan(0)
server.getIO().close()
})
it('does not allow client messages to overwrite server-created workspace diff cards', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
const { saved, payload } = await saveDiff(storage, 'room-1', 'cccccccccccccccccccccccccccccccc')
const originalTimestamp = saved!.message.timestamp
const overwrite = storage.saveMessageAndRefreshRoom({
id: saved!.message.id,
roomId: 'room-1',
senderId: 'user-1',
senderName: 'Mallory',
content: JSON.stringify({ kind: 'workspace_diff', change_id: 'other-change' }),
timestamp: 1,
role: 'tool',
tool_name: 'workspace_diff',
tool_call_id: 'workspace_diff:fake',
})
expect(overwrite.message.timestamp).toBe(originalTimestamp)
expect(JSON.parse(String(overwrite.message.content)).change_id).toBe(payload.change_id)
expect(countRows('workspace_run_changes', ' WHERE change_id = ?', payload.change_id)).toBe(1)
server.getIO().close()
})
it('cleans persisted workspace diffs when a room is deleted', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
const { payload } = await saveDiff(storage, 'room-1', 'dddddddddddddddddddddddddddddddd')
storage.deleteRoom('room-1')
expectWorkspaceChangeDeleted(payload.change_id)
expect(countRows('gc_messages', ' WHERE roomId = ?', 'room-1')).toBe(0)
expect(countRows('gc_rooms', ' WHERE id = ?', 'room-1')).toBe(0)
server.getIO().close()
})
it('does not write workspace diff rows after a room is deleted', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
const draft = await makeDraft('99999999999999999999999999999999')
storage.deleteRoom('room-1')
const saved = storage.saveWorkspaceDiffMessageForRun({
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'Worker',
sessionId: 'session-1',
runId: '99999999999999999999999999999999',
status: 'completed',
workspace,
draft: draft!,
})
expect(saved).toBeNull()
expect(countRows('gc_messages', ' WHERE roomId = ?', 'room-1')).toBe(0)
expect(countRows('workspace_run_changes')).toBe(0)
expect(countRows('workspace_run_change_files')).toBe(0)
server.getIO().close()
})
it('cleans persisted workspace diffs when their group messages are pruned', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
const { saved, payload } = await saveDiff(storage, 'room-1', 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee')
dbState.db?.prepare('UPDATE gc_messages SET timestamp = ? WHERE id = ?').run(1, saved!.message.id)
storage.saveMessageAndRefreshRoom({
id: 'keep-message',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User',
content: 'keep',
timestamp: 100,
role: 'user',
})
storage.pruneMessages('room-1', 1)
expectWorkspaceChangeDeleted(payload.change_id)
expect(countRows('gc_messages', ' WHERE id = ?', saved!.message.id)).toBe(0)
expect(countRows('gc_messages', ' WHERE id = ?', 'keep-message')).toBe(1)
server.getIO().close()
})
it('rolls back diff rows when the group message insert fails', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.saveRoom('room-1', 'Room 1')
const draft = await makeDraft('fedcba9876543210fedcba9876543210')
dbState.db?.exec(`CREATE TRIGGER fail_workspace_diff_message
BEFORE INSERT ON gc_messages
WHEN NEW.tool_name = 'workspace_diff'
BEGIN
SELECT RAISE(ABORT, 'message failed');
END`)
expect(() => storage.saveWorkspaceDiffMessageForRun({
roomId: 'room-1',
senderId: 'agent-1',
senderName: 'Worker',
sessionId: 'session-1',
runId: 'fedcba9876543210fedcba9876543210',
status: 'failed',
workspace,
draft: draft!,
})).toThrow('message failed')
expect((dbState.db?.prepare('SELECT COUNT(*) AS count FROM workspace_run_changes').get() as { count: number }).count).toBe(0)
expect((dbState.db?.prepare('SELECT COUNT(*) AS count FROM workspace_run_change_files').get() as { count: number }).count).toBe(0)
expect((dbState.db?.prepare('SELECT COUNT(*) AS count FROM gc_messages WHERE tool_name = ?').get('workspace_diff') as { count: number }).count).toBe(0)
server.getIO().close()
})
})