import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import assert from 'node:assert/strict'; import test from 'node:test'; import { encodeAttributes, decodeAttributes } from '../src/crdt.js'; import { buildHermesPrompt } from '../src/hermes.js'; import { createReplyMutation } from '../src/mutations.js'; import { processNodeUpdate, type NodeCache } from '../src/processor.js'; import { StateStore } from '../src/state.js'; import type { BlockLeaf, MessageAttributes, SyncNodeUpdateData } from '../src/types.js'; const botUserId = 'bot-user'; test('mention extraction gates replies and ignores non-mentioned messages', async () => { const env = await setupProcessor(); await processNodeUpdate({ ...env, update: makeUpdate('msg1', 'human', messageAttrs('msg1', 'parent', [{ type: 'text', text: 'hello' }])) }); assert.equal(env.created.length, 0); }); test('mentioned messages invoke Hermes and dedupe by source message id', async () => { const env = await setupProcessor(); const update = makeUpdate('msg2', 'human', messageAttrs('msg2', 'parent', [ { type: 'mention', text: '@bot', attrs: { id: 'm1', target: botUserId } }, { type: 'text', text: ' please summarize this' } ])); await processNodeUpdate({ ...env, update }); await processNodeUpdate({ ...env, update }); assert.equal(env.prompts.length, 1); assert.equal(env.created.length, 1); }); test('messages created by the bot are ignored even when they mention the bot', async () => { const env = await setupProcessor(); await processNodeUpdate({ ...env, update: makeUpdate('msg3', botUserId, messageAttrs('msg3', 'parent', [ { type: 'mention', attrs: { id: 'm1', target: botUserId } } ])) }); assert.equal(env.created.length, 0); }); test('prompt includes safety instruction, mentioned message, and parent context', () => { const prompt = buildHermesPrompt({ messageText: 'Can you help?', parentText: 'Family logistics thread' }); assert.match(prompt, /Colanode family message/); assert.match(prompt, /Do not reveal secrets/); assert.match(prompt, /Can you help\?/); assert.match(prompt, /Family logistics thread/); }); test('reply mutation encodes a decodable Colanode message attributes document', () => { const { mutation, nodeId } = createReplyMutation('parent-id', 'A helpful reply.', new Date('2026-01-01T00:00:00.000Z')); const data = mutationData(mutation).data; const attrs = decodeAttributes(data); assert.equal(attrs.type, 'message'); assert.equal(attrs.subtype, 'standard'); assert.equal(attrs.name, 'Reyna Family Bot'); assert.equal(attrs.parentId, 'parent-id'); assert.equal(Object.values(attrs.content ?? {})[0]?.content?.[0]?.text, 'A helpful reply.'); }); async function setupProcessor() { const dir = await mkdtemp(join(tmpdir(), 'bridge-test-')); const state = new StateStore(join(dir, 'state.json')); await state.load(); test.after(async () => rm(dir, { recursive: true, force: true })); const prompts: string[] = []; const created: unknown[] = []; const cache: NodeCache = new Map([ ['parent', { id: 'parent', createdBy: 'human', attrs: messageAttrs('parent', 'thread', [{ type: 'text', text: 'Parent context' }]) }] ]); return { botUserId, workspaceId: 'workspace', cache, state, hermes: { bin: 'hermes', timeoutMs: 1000, maxOutputBytes: 1000 }, colanode: { createMessage: async (_workspaceId: string, mutation: unknown) => { created.push(mutation); } } as never, runHermesFn: async (prompt: string) => { prompts.push(prompt); return 'Hermes reply'; }, prompts, created }; } function makeUpdate(nodeId: string, createdBy: string, attrs: MessageAttributes): SyncNodeUpdateData { return { id: `${nodeId}-update`, nodeId, rootId: 'root', workspaceId: 'workspace', revision: '1', data: encodeAttributes(attrs as unknown as Record), createdAt: new Date().toISOString(), createdBy }; } function messageAttrs(id: string, parentId: string, content: BlockLeaf[]): MessageAttributes { const blockId = `${id}-block`; return { type: 'message', subtype: 'standard', parentId, content: { [blockId]: { id: blockId, type: 'paragraph', parentId: id, index: 'a0', content } } }; } function mutationData(mutation: unknown): { data: string } { const outer = mutation as { data: { data: string } }; return outer.data; }