Files
FamReynaBrain/projects/colanode-hermes-bridge/test/bridge.test.ts
T
2026-09-14 22:38:47 -04:00

121 lines
4.4 KiB
TypeScript

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<MessageAttributes>(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<string, unknown>),
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;
}