Files
hermes-ui/tests/server/group-chat-workspace-diff.test.ts
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
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.
2026-10-16 10:34:35 +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()
})
})