121 lines
4.4 KiB
TypeScript
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;
|
|
}
|