mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-13 20:20:46 +00:00
343 lines
13 KiB
TypeScript
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()
|
|
})
|
|
})
|