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, mediaExchange?: { detect(conversationId: string, text: string, previousSelfText?: string): string | null; handle(conversationId: string, incomingKey: string, text: string, previousSelfText?: string): Promise; getOutboundAttribution?(conversationId: string): { eventIds: string[]; texts: string[] }; }, options?: ConversationAutomationOptions, ) { const triage = { setStatus: vi.fn() }; const saved = new Map(); 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) => 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.detect).toHaveBeenCalledWith('room-alice', 'got any pics?', 'hello'); expect(mediaExchange.handle).toHaveBeenCalledWith('room-alice', 'in-1', 'got any pics?', 'hello'); expect(fixture.ai.generateReply).not.toHaveBeenCalled(); expect(fixture.getSaved()).toMatchObject({ askedForMedia: true, automation: { turnsUsed: 0, status: 'active' } }); }); it('passes a recipient acceptance and the preceding bot media offer to the exchange path', async () => { const mediaExchange = { detect: vi.fn((_room: string, text: string, previous?: string) => ( text === 'yes please' && previous === 'Want me to send you a pic?' ? 'generic' : null )), handle: vi.fn().mockResolvedValue({ kind: 'sent', assetId: 'asset-one', requestKind: 'generic' }), }; const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, mediaExchange); const conversation = incoming(1, 'yes please'); conversation.messages[0].text = 'Want me to send you a pic?'; fixture.openers.getByChatRoomId.mockReturnValue({ sentAt: 0, subreddit: 'r4r', message: 'Want me to send you a pic?', }); await expect(fixture.service.processConversation(conversation)).resolves.toMatchObject({ kind: 'sent' }); expect(mediaExchange.detect).toHaveBeenCalledWith( 'room-alice', 'yes please', 'Want me to send you a pic?', ); expect(mediaExchange.handle).toHaveBeenCalledWith( 'room-alice', 'in-1', 'yes please', 'Want me to send you a pic?', ); expect(fixture.ai.generateReply).not.toHaveBeenCalled(); }); 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: 4, 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: 4, 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(); }); });