Files
RedditBot/tests/conversation-automation.test.ts

839 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest';
import {
ConversationAutomationService,
type ConversationAutomationOptions,
} from '../src/services/conversation-automation.js';
import type { ConversationState } from '../src/services/conversation-state.js';
import type { ActiveChat } from '../src/services/chat.js';
function harness(
sellerKeywords: string[] = [],
sellerDomains: string[] = [],
openerSentAt: number | null = 0,
subredditConversation?: {
mode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
maxAutomatedTurns: number;
rapportTurnsBeforeMediaAsk?: number;
},
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
mediaExchange?: {
detect(conversationId: string, text: string): string | null;
handle(conversationId: string, incomingKey: string, text: string): Promise<object>;
getOutboundAttribution?(conversationId: string): { eventIds: string[]; texts: string[] };
},
options?: ConversationAutomationOptions,
) {
const triage = { setStatus: vi.fn() };
const saved = new Map<string, ConversationState>();
let lastSaved: ConversationState | undefined;
const chat = {
refreshTopChats: vi.fn().mockResolvedValue(undefined),
getCachedConversations: vi.fn().mockResolvedValue([]),
sendChatMessage: vi.fn().mockResolvedValue(true),
};
const ai = {
generateMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?'),
generateR4RMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?'),
generateReply: vi.fn().mockResolvedValue('That sounds fun. What got you into it?'),
};
const states = {
get: vi.fn((id: string) => saved.get(id)),
save: vi.fn((id: string, state: ConversationState) => {
lastSaved = structuredClone(state);
saved.set(id, lastSaved);
}),
};
const openers = {
getByChatRoomId: vi.fn(() => openerSentAt === null
? undefined
: { sentAt: openerSentAt, subreddit: 'r4r', message: 'hello' }),
};
const claims = {
tryClaim: vi.fn(() => true),
getClaim: vi.fn(() => undefined),
listPending: vi.fn(() => []),
markAttempt: vi.fn(() => true),
releasePending: vi.fn(() => true),
markSent: vi.fn(() => true),
markSuperseded: vi.fn(() => true),
};
const rateLimiter = {
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
};
const logger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn() };
const service = new ConversationAutomationService(
chat as never, ai as never, states as never, openers as never, claims, rateLimiter as never, logger as never,
false, sellerKeywords, sellerDomains,
() => subredditConversation ? [{
name: 'r4r', enabled: true, type: 'chat', tone: 'default', conversation: {
...subredditConversation,
rapportTurnsBeforeMediaAsk: subredditConversation.rapportTurnsBeforeMediaAsk ?? 3,
},
}] : [],
() => 1_000,
0,
beforeSendLoader ?? (async (_conversationId, observed) => observed),
mediaExchange as never,
{ triage, ...options },
);
return { service, chat, ai, states, openers, claims, rateLimiter, triage, getSaved: () => lastSaved };
}
function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) {
return {
username: 'alice', roomName: 'alice', roomId: 'room-alice', url: '/room/room-alice',
lastMessageTime: turn, lastMessageSnippet: text, isFromUs: false, unread: true,
messages: [
{ eventId: `out-${turn}`, sender: 'me', text: 'hello', timestamp: turn * 2, isSelf: true },
{ eventId: `in-${turn}`, sender: 'alice', text, timestamp: turn * 2 + 1, isSelf: false, media },
],
};
}
describe('conversation automation', () => {
it('handles a requested library image through the reply limiter before generating text', async () => {
const mediaExchange = {
detect: vi.fn().mockReturnValue('generic'),
handle: vi.fn().mockResolvedValue({ kind: 'sent', assetId: 'asset-one', requestKind: 'generic' }),
};
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, mediaExchange);
const result = await fixture.service.processConversation(incoming(1, 'got any pics?'));
expect(result?.kind).toBe('sent');
expect(fixture.rateLimiter.throttle).toHaveBeenCalledOnce();
expect(mediaExchange.handle).toHaveBeenCalledWith('room-alice', 'in-1', 'got any pics?');
expect(fixture.ai.generateReply).not.toHaveBeenCalled();
expect(fixture.getSaved()).toMatchObject({ askedForMedia: true, automation: { turnsUsed: 0, status: 'active' } });
});
it('allows only one autonomous media request and then hands off', async () => {
const { service, chat, getSaved } = harness();
expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2);
const second = await service.processConversation(incoming(2));
expect(second).toMatchObject({ kind: 'human-required', remainingTurns: 2 });
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 1, status: 'human-required' });
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(1);
expect(await service.processConversation(incoming(2))).toBeNull();
});
it('stops without sending when the user declines images', async () => {
const { service, chat, getSaved } = harness();
const result = await service.processConversation(incoming(1, "No thanks, I don't send photos."));
expect(result?.kind).toBe('boundary');
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(getSaved()?.automation.status).toBe('boundary');
});
it('hands off immediately when the opener already made the conversation media request', async () => {
const fixture = harness();
const conversation = incoming(1, 'Sure');
conversation.messages[0].text = 'If you feel like it, send a pic where you are smiling.';
fixture.openers.getByChatRoomId.mockReturnValue({
sentAt: 0,
subreddit: 'r4r',
message: 'If you feel like it, send a pic where you are smiling.',
});
const result = await fixture.service.processConversation(conversation);
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 3 });
expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled();
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.getSaved()?.automation.handoffReason).toContain('media request has already been made');
});
it('supersedes a queued reply when a refusal arrives during the rate-limit wait', async () => {
const tattooEvent = {
eventId: 'tattoo-event', sender: 'alice',
text: 'I edit out my tattoos for the sake of anonymity but that is the only filtering done.',
timestamp: 30, isSelf: false,
};
const observed: ActiveChat = {
username: 'JustProwling', roomName: 'JustProwling', roomId: 'room-alice', url: '/room/room-alice',
lastMessageTime: 30, lastMessageSnippet: tattooEvent.text, isFromUs: false, unread: true,
messages: [
{ eventId: 'opener', sender: 'me', text: 'Send a pic where you are smiling.', timestamp: 10, isSelf: true },
tattooEvent,
],
};
const latest: ActiveChat = {
...observed,
lastMessageTime: 40,
lastMessageSnippet: 'Zero interest in sending you anything lol',
messages: [
...observed.messages!,
{
eventId: 'refusal-event', sender: 'alice',
text: 'Zero interest in sending you anything lol', timestamp: 40, isSelf: false,
},
],
};
const fixture = harness([], [], 0, undefined, async () => latest);
fixture.openers.getByChatRoomId.mockReturnValue({
sentAt: 0,
subreddit: 'r4r',
message: 'Send a pic where you are smiling.',
});
fixture.claims.getClaim.mockReturnValue({
conversationId: 'room-alice', incomingKey: 'tattoo-event', status: 'pending',
replyText: 'If you ever feel like sharing a quick snap, I am all eyes.', incomingAt: 30,
claimedAt: 0, lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
} as never);
const result = await fixture.service.processConversation(observed);
expect(result?.kind).toBe('boundary');
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'tattoo-event');
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.getSaved()).toMatchObject({
boundaries: ['does not want to send photos or videos'],
automation: { status: 'boundary', lastIncomingKey: 'refusal-event' },
});
});
it('kills a conversation that pushes a seller funnel', async () => {
const { service, chat, ai, triage, getSaved } = harness(['onlyfans'], ['fansly.com']);
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
expect(result).toMatchObject({ kind: 'killed', remainingTurns: 0 });
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(getSaved()?.automation).toMatchObject({
status: 'killed',
handoffReason: 'Seller funnel detected (Fansly: "fansly").',
});
expect(getSaved()?.sellerDetected).toBe(true);
expect(triage.setStatus).toHaveBeenCalledWith('room-alice', 'done', null, 1_000);
});
it('kills a conversation that moves to another messaging app, whatever the policy mode', async () => {
const { service, chat, triage, getSaved } = harness();
const result = await service.processConversation(incoming(1, 'add me on telegram, im barely on here'));
expect(result).toMatchObject({ kind: 'killed', remainingTurns: 0 });
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(getSaved()?.automation).toMatchObject({ status: 'killed', lastIncomingKey: 'in-1' });
expect(getSaved()?.automation.handoffReason).toContain('Telegram');
expect(triage.setStatus).toHaveBeenCalledWith('room-alice', 'done', null, 1_000);
});
it('kills an obfuscated off-platform push', async () => {
const { service, getSaved } = harness();
const result = await service.processConversation(incoming(1, 'my t3l3gr4m is @jess1'));
expect(result?.kind).toBe('killed');
expect(getSaved()?.automation.handoffReason).toContain('Telegram');
});
it('does not treat a photo request as an off-platform push', async () => {
const { service, chat } = harness();
const result = await service.processConversation(incoming(1, 'send me a snap and ill send one back'));
expect(result?.kind).toBe('sent');
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
});
it('leaves off-platform kills disabled when the operator turns them off', async () => {
const { service, getSaved } = harness([], [], 0, undefined, undefined, undefined, { offPlatformKill: false });
const result = await service.processConversation(incoming(1, 'my telegram is @jess'));
expect(result?.kind).toBe('sent');
expect(getSaved()?.automation.status).toBe('active');
});
it('hands off instead of killing when seller keywords are configured not to kill', async () => {
const { service, triage, getSaved } = harness(['spoil me'], [], 0, undefined, undefined, undefined, {
killOnSellerKeyword: false,
});
const result = await service.processConversation(incoming(1, 'you could spoil me a little'));
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 3 });
expect(getSaved()?.automation).toMatchObject({
status: 'human-required',
handoffReason: 'Potential upsell detected (keyword: spoil me).',
});
expect(triage.setStatus).not.toHaveBeenCalled();
});
it('kills on a high-confidence AI screening verdict', async () => {
const screener = {
screenConversationReply: vi.fn().mockResolvedValue({
classification: 'seller', confidence: 0.93, evidence: 'everything good is behind her link',
}),
};
const { service, chat, getSaved } = harness([], [], 0, undefined, undefined, undefined, {
screener,
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
});
const result = await service.processConversation(incoming(1, 'the good stuff is elsewhere babe'));
expect(result?.kind).toBe('killed');
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(getSaved()?.automation.handoffReason).toBe(
'AI screening classified this conversation as seller (confidence 0.93): everything good is behind her link',
);
});
it('hands off rather than killing on a mid-confidence AI screening verdict', async () => {
const screener = {
screenConversationReply: vi.fn().mockResolvedValue({
classification: 'scam', confidence: 0.65, evidence: 'asked for a verification code',
}),
};
const { service, triage, getSaved } = harness([], [], 0, undefined, undefined, undefined, {
screener,
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
});
const result = await service.processConversation(incoming(1, 'can you help me with something first'));
expect(result?.kind).toBe('human-required');
expect(getSaved()?.automation.status).toBe('human-required');
expect(triage.setStatus).not.toHaveBeenCalled();
});
it('replies as normal when screening returns a genuine verdict, and screens once per inbound event', async () => {
const screener = {
screenConversationReply: vi.fn().mockResolvedValue({
classification: 'genuine', confidence: 0.99, evidence: 'answering the question asked',
}),
};
const { service, chat } = harness([], [], 0, undefined, undefined, undefined, {
screener,
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
});
expect((await service.processConversation(incoming(1)))?.kind).toBe('sent');
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
expect(screener.screenConversationReply).toHaveBeenCalledOnce();
});
it('fails open and replies when the screener throws', async () => {
const screener = {
screenConversationReply: vi.fn().mockRejectedValue(new Error('openrouter unavailable')),
};
const { service, chat } = harness([], [], 0, undefined, undefined, undefined, {
screener,
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
});
expect((await service.processConversation(incoming(1)))?.kind).toBe('sent');
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
});
it('re-reads the screening options on every reply so a config change applies without a restart', async () => {
const screener = {
screenConversationReply: vi.fn().mockResolvedValue({
classification: 'seller', confidence: 0.95, evidence: 'selling',
}),
};
const screening = { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 };
const { service, getSaved } = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, undefined, {
screener,
screening,
});
expect((await service.processConversation(incoming(1)))?.kind).toBe('sent');
expect(screener.screenConversationReply).not.toHaveBeenCalled();
screening.enabled = true;
expect((await service.processConversation(incoming(2)))?.kind).toBe('killed');
expect(screener.screenConversationReply).toHaveBeenCalledOnce();
expect(getSaved()?.automation.status).toBe('killed');
});
it('does not call the screener at all when screening is disabled', async () => {
const screener = { screenConversationReply: vi.fn() };
const { service } = harness([], [], 0, undefined, undefined, undefined, {
screener,
screening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
});
await service.processConversation(incoming(1));
expect(screener.screenConversationReply).not.toHaveBeenCalled();
});
it('completes when an inbound image is received', async () => {
const { service, chat, getSaved } = harness();
const result = await service.processConversation(incoming(1, '[Image]', [{
originalUrl: 'mxc://image', localPath: 'image.jpg', fileName: 'image.jpg',
}]));
expect(result?.kind).toBe('image-received');
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(getSaved()?.automation.status).toBe('image-received');
});
it('does not generate or send after a human has overridden the thread', async () => {
const { service, chat, ai, states } = harness();
const conversation = incoming(1);
await service.processConversation(conversation);
const state = states.save.mock.calls.at(-1)?.[1] as ConversationState;
state.automation.status = 'user-overridden';
states.save('room-alice', state);
const result = await service.processConversation(incoming(2));
expect(result).toBeNull();
expect(chat.sendChatMessage).toHaveBeenCalledTimes(1);
expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1);
});
it('persists a human override when a direct Reddit message appears before the next inbound reply', async () => {
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 });
const conversation = incoming(2, 'recipient follow-up');
conversation.messages = [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'first-inbound', sender: 'alice', text: 'first reply', timestamp: 2, isSelf: false },
{ eventId: 'manual', sender: 'me', text: 'operator reply', timestamp: 3, isSelf: true },
{ eventId: 'next-inbound', sender: 'alice', text: 'recipient follow-up', timestamp: 4, isSelf: false },
];
expect(await fixture.service.processConversation(conversation)).toBeNull();
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.ai.generateReply).not.toHaveBeenCalled();
expect(fixture.getSaved()?.automation).toMatchObject({
status: 'user-overridden',
handoffReason: 'A manual reply was observed in this conversation.',
});
});
it('does not mistake a recorded automated reply for an operator message', async () => {
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 });
const first = await fixture.service.processConversation(incoming(1));
expect(first?.kind).toBe('sent');
const conversation = incoming(2, 'second reply');
conversation.messages = [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'first-inbound', sender: 'alice', text: 'first reply', timestamp: 2, isSelf: false },
{ eventId: 'automated', sender: 'me', text: 'That sounds fun. What got you into it?', timestamp: 3, isSelf: true },
{ eventId: 'second-inbound', sender: 'alice', text: 'second reply', timestamp: 4, isSelf: false },
];
expect((await fixture.service.processConversation(conversation))?.kind).toBe('sent');
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(2);
expect(fixture.getSaved()?.automation.status).toBe('active');
});
it('does not mistake confirmed exchange-library sends for operator messages', async () => {
const reciprocal = 'Your turn — send me one back if you’re comfortable.';
const mediaExchange = {
detect: vi.fn().mockReturnValue(null),
handle: vi.fn(),
getOutboundAttribution: vi.fn().mockReturnValue({
eventIds: ['library-image'],
texts: [reciprocal],
}),
};
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, mediaExchange);
const conversation = incoming(2, 'thanks');
conversation.messages = [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'request', sender: 'alice', text: 'send a pic', timestamp: 2, isSelf: false },
{ eventId: 'library-image', sender: 'me', text: 'image.jpg', timestamp: 3, isSelf: true },
{ eventId: 'library-text', sender: 'me', text: reciprocal, timestamp: 4, isSelf: true },
{ eventId: 'thanks', sender: 'alice', text: 'thanks', timestamp: 5, isSelf: false },
];
expect((await fixture.service.processConversation(conversation))?.kind).toBe('sent');
expect(fixture.getSaved()?.automation.status).toBe('active');
});
it('supersedes a pending reply when Matrix contains a different operator-authored message', async () => {
const fixture = harness();
fixture.claims.listPending.mockReturnValue([{
conversationId: 'room-alice', incomingKey: 'first-inbound', status: 'pending',
replyText: 'Expected automated reply', incomingAt: 2, claimedAt: 2,
lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
}] as never);
fixture.chat.getCachedConversations.mockResolvedValue([{
...incoming(2),
messages: [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'first-inbound', sender: 'alice', text: 'first reply', timestamp: 2, isSelf: false },
{ eventId: 'manual', sender: 'me', text: 'Different operator reply', timestamp: 3, isSelf: true },
],
}]);
await fixture.service.processPendingConversations();
expect(fixture.claims.markSent).not.toHaveBeenCalled();
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'first-inbound');
});
it('does not reply when the room has no recorded opener', async () => {
const { service, chat, ai, claims } = harness([], [], null);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(claims.tryClaim).not.toHaveBeenCalled();
});
it('does not reply to an inbound event older than the recorded opener', async () => {
const { service, chat, ai, claims } = harness([], [], 4);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(claims.tryClaim).not.toHaveBeenCalled();
});
it('claims an inbound event durably before sending and marks it sent afterwards', async () => {
const { service, chat, claims } = harness();
await service.processConversation(incoming(1));
expect(claims.tryClaim).toHaveBeenCalledWith(
'room-alice',
'in-1',
'Would you be comfortable sharing a selfie?',
3,
);
expect(claims.tryClaim.mock.invocationCallOrder[0])
.toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
expect(claims.markAttempt).toHaveBeenCalledWith('room-alice', 'in-1');
});
it('refreshes chats and keeps the backlog visible in scan mode without replying', async () => {
const { service, chat, ai } = harness();
chat.getCachedConversations.mockResolvedValue([incoming(1)]);
const results = await service.processPendingConversations(undefined, { scanOnly: true });
expect(results).toEqual([]);
expect(chat.refreshTopChats).toHaveBeenCalledWith(25);
// No AI spend and no outbound traffic — the reply is left for a human.
expect(ai.generateReply).not.toHaveBeenCalled();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(service.getPriorityStatus()).toMatchObject({ actionableBacklog: 1 });
expect(await service.hasActionableReplies()).toBe(true);
});
it('refreshes a wider reply window and drains actionable conversations oldest first', async () => {
const { service, chat } = harness();
const newer = { ...incoming(2), username: 'newer', roomId: 'room-newer', url: '/room/newer' };
const older = { ...incoming(1), username: 'older', roomId: 'room-older', url: '/room/older' };
chat.getCachedConversations.mockResolvedValue([newer, older]);
await service.processPendingConversations();
expect(chat.refreshTopChats).toHaveBeenCalledWith(25);
expect(chat.sendChatMessage.mock.calls.map(([url]) => url)).toEqual(['/room/older', '/room/newer']);
});
it('blocks outreach while an actionable reply exists and clears after processing', async () => {
const { service, chat } = harness();
chat.getCachedConversations.mockResolvedValue([incoming(1)]);
expect(await service.hasActionableReplies()).toBe(true);
await service.processPendingConversations();
expect(await service.hasActionableReplies()).toBe(false);
expect(service.getPriorityStatus()).toMatchObject({
processing: false,
actionableBacklog: 0,
lastScanAt: 1_000,
lastReplySentAt: 1_000,
lastReplyLatencyMs: 997,
});
});
it('submits replies to the rate limiter at high priority', async () => {
const { service, rateLimiter } = harness();
await service.processConversation(incoming(1));
expect(rateLimiter.throttle).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
undefined,
100,
);
});
it('processes a durable inbound event even when Matrix reports the room as read', async () => {
const { service, chat, claims } = harness();
const conversation = { ...incoming(501), unread: false };
chat.getCachedConversations.mockResolvedValue([]);
await service.processPendingConversations();
chat.getCachedConversations.mockResolvedValue([conversation]);
expect(await service.hasActionableReplies()).toBe(true);
await service.processPendingConversations();
expect(claims.tryClaim).toHaveBeenCalledWith(
'room-alice',
'in-501',
'Would you be comfortable sharing a selfie?',
1_003,
);
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
});
it('does not revive a historical read conversation on the first scan after startup', async () => {
const { service, chat, claims } = harness();
chat.getCachedConversations.mockResolvedValue([{ ...incoming(1), unread: false }]);
await service.processPendingConversations();
expect(claims.tryClaim).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
it('retries the same durable reply after an interrupted send', async () => {
const { service, chat, ai, claims } = harness();
chat.sendChatMessage.mockRejectedValueOnce(new Error('interrupted'));
await expect(service.processConversation(incoming(1))).rejects.toThrow('interrupted');
claims.getClaim.mockReturnValue({
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending',
replyText: 'Would you be comfortable sharing a selfie?', incomingAt: 3,
claimedAt: 0, lastAttemptAt: 10, attemptCount: 1, sentAt: null,
} as never);
chat.sendChatMessage.mockResolvedValue(true);
const result = await service.processConversation(incoming(1));
expect(result?.kind).toBe('sent');
expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1);
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
});
it('hands legacy pending claims without a stored reply to a human without blocking outreach forever', async () => {
const { service, chat, claims, getSaved } = harness();
claims.getClaim.mockReturnValue({
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending',
replyText: null, incomingAt: null, claimedAt: 0,
lastAttemptAt: null, attemptCount: 0, sentAt: null,
} as never);
chat.getCachedConversations.mockResolvedValue([incoming(1)]);
const result = await service.processConversation(incoming(1));
expect(result?.kind).toBe('human-required');
expect(claims.releasePending).toHaveBeenCalledWith('room-alice', 'in-1');
expect(getSaved()?.automation.handoffReason).toContain('no recoverable message body');
claims.getClaim.mockReturnValue(undefined);
expect(await service.hasActionableReplies()).toBe(false);
});
it('reconciles an interrupted claim when Matrix already contains the reply', async () => {
const { service, chat, claims } = harness();
const conversation = {
...incoming(1), unread: false, isFromUs: true, lastMessageTime: 4,
messages: [
...incoming(1).messages,
{ eventId: 'out-reply', sender: 'me', text: 'Would you be comfortable sharing a selfie?', timestamp: 4, isSelf: true },
],
};
const pending = {
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending' as const,
replyText: 'Would you be comfortable sharing a selfie?', incomingAt: 3,
claimedAt: 0, lastAttemptAt: 10, attemptCount: 1, sentAt: null,
};
claims.listPending.mockReturnValue([pending] as never);
chat.getCachedConversations.mockResolvedValue([conversation]);
await service.processPendingConversations();
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1', 'out-reply');
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
it('does not send when another worker already claimed the inbound event', async () => {
const { service, chat, claims } = harness();
claims.tryClaim.mockReturnValue(false);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
it('uses autonomous general replies and the configured turn limit for an r4r subreddit', async () => {
const { service, ai, chat, getSaved } = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 2 });
expect((await service.processConversation(incoming(1)))?.kind).toBe('sent');
const result = await service.processConversation(incoming(2));
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
expect(ai.generateReply).toHaveBeenCalledTimes(2);
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
expect(getSaved()?.automation).toMatchObject({
policyMode: 'r4r', policyVersion: 3, maxAutomatedTurns: 2, turnsUsed: 2,
});
expect(getSaved()?.askedForMedia).toBe(false);
});
it('snapshots the r4r policy so later configuration changes do not switch an active conversation', async () => {
const behaviour: {
mode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
maxAutomatedTurns: number;
} = {
mode: 'r4r', maxAutomatedTurns: 2,
};
const { service, ai, getSaved } = harness([], [], 0, behaviour);
await service.processConversation(incoming(1));
behaviour.mode = 'manual';
await service.processConversation(incoming(2));
expect(ai.generateReply).toHaveBeenCalledTimes(2);
expect(getSaved()?.automation.policyMode).toBe('r4r');
});
it('marks manual conversations for human handling once without generating or sending', async () => {
const { service, ai, chat, getSaved } = harness([], [], 0, { mode: 'manual', maxAutomatedTurns: 3 });
const result = await service.processConversation(incoming(1));
expect(result?.kind).toBe('human-required');
expect(getSaved()?.automation).toMatchObject({ policyMode: 'manual', status: 'human-required' });
expect(ai.generateReply).not.toHaveBeenCalled();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(await service.processConversation(incoming(1))).toBeNull();
});
it('hands r4r conversations to a human when age or contact safety gates trigger', async () => {
const underage = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 });
const result = await underage.service.processConversation(incoming(1, "I'm 17, what about you?"));
expect(result?.kind).toBe('human-required');
expect(underage.getSaved()?.automation.handoffReason).toBe('The participant may be under 18.');
expect(underage.chat.sendChatMessage).not.toHaveBeenCalled();
});
it('does not send an r4r generation that asks for media', async () => {
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 });
fixture.ai.generateReply.mockResolvedValue('Would you send me a nude?');
const result = await fixture.service.processConversation(incoming(1));
expect(result?.kind).toBe('human-required');
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.getSaved()?.automation.handoffReason)
.toBe('The generated reply was rejected: reply solicits media outside the configured media-ask phase.');
});
it('regenerates once when a legacy media ask contains no recognizable request', async () => {
const fixture = harness();
fixture.ai.generateMediaAsk
.mockResolvedValueOnce('That sounds fun. I would love to see that smile.')
.mockResolvedValueOnce('If you are up for it, snap a cute pic of yourself?');
const result = await fixture.service.processConversation(incoming(1));
expect(result).toMatchObject({ kind: 'sent', remainingTurns: 2 });
expect(fixture.ai.generateMediaAsk).toHaveBeenCalledTimes(2);
expect(fixture.chat.sendChatMessage).toHaveBeenCalledWith(
expect.any(String),
'If you are up for it, snap a cute pic of yourself?',
);
});
it('preserves unused turns and records the exact reason when regeneration is still invalid', async () => {
const fixture = harness();
fixture.ai.generateMediaAsk.mockResolvedValue('That sounds fun. I would love to see that smile.');
const result = await fixture.service.processConversation(incoming(1));
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 3 });
expect(fixture.ai.generateMediaAsk).toHaveBeenCalledTimes(2);
expect(fixture.getSaved()?.automation).toMatchObject({
turnsUsed: 0,
handoffReason: 'The generated media ask was rejected: media ask does not contain a recognizable image or video request.',
});
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
});
it('builds rapport before making exactly one optional media ask in hybrid mode', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 2,
});
await fixture.service.processConversation(incoming(1));
await fixture.service.processConversation(incoming(2));
const mediaAsk = await fixture.service.processConversation(incoming(3));
await fixture.service.processConversation(incoming(4));
expect(mediaAsk?.kind).toBe('sent');
expect(fixture.ai.generateReply).toHaveBeenCalledTimes(3);
expect(fixture.ai.generateR4RMediaAsk).toHaveBeenCalledTimes(1);
expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled();
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(4);
expect(fixture.getSaved()?.askedForMedia).toBe(true);
expect(fixture.getSaved()?.automation).toMatchObject({
policyMode: 'r4r-media',
policyVersion: 3,
rapportTurnsBeforeMediaAsk: 2,
hybridPhase: 'post-media-ask',
turnsUsed: 4,
});
});
it('stops the hybrid flow when the media ask is declined', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 1,
});
await fixture.service.processConversation(incoming(1));
await fixture.service.processConversation(incoming(2));
const result = await fixture.service.processConversation(incoming(3, "No thanks, I don't send photos."));
expect(result?.kind).toBe('boundary');
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(2);
expect(fixture.ai.generateR4RMediaAsk).toHaveBeenCalledTimes(1);
});
it('can record an image received after the media ask consumes the final turn', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 2, rapportTurnsBeforeMediaAsk: 1,
});
await fixture.service.processConversation(incoming(1));
const mediaAsk = await fixture.service.processConversation(incoming(2));
const result = await fixture.service.processConversation(incoming(3, '[Image]', [{
originalUrl: 'mxc://image', localPath: 'image.jpg', fileName: 'image.jpg',
}]));
expect(mediaAsk).toMatchObject({ kind: 'sent', remainingTurns: 0 });
expect(result?.kind).toBe('image-received');
expect(fixture.getSaved()?.automation.status).toBe('image-received');
});
it('rejects an unsafe hybrid media generation without sending it', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 4, rapportTurnsBeforeMediaAsk: 1,
});
fixture.ai.generateR4RMediaAsk.mockResolvedValue('Send me a nude.');
await fixture.service.processConversation(incoming(1));
const result = await fixture.service.processConversation(incoming(2));
expect(result?.kind).toBe('human-required');
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(1);
});
it('rejects a coercive legacy media request', async () => {
const fixture = harness();
fixture.ai.generateMediaAsk.mockResolvedValue('Prove me wrong and send a pic.');
const result = await fixture.service.processConversation(incoming(1));
expect(result?.kind).toBe('human-required');
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
});
});