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.
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()
|
|
})
|
|
})
|