519 lines
22 KiB
TypeScript
519 lines
22 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { ConversationAutomationService } 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>,
|
|
) {
|
|
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' }),
|
|
};
|
|
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() };
|
|
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),
|
|
);
|
|
return { service, chat, ai, states, openers, claims, rateLimiter, 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('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: 0 });
|
|
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.';
|
|
|
|
const result = await fixture.service.processConversation(conversation);
|
|
|
|
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
|
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.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('flags a potential upsell for human handoff without generating a reply', async () => {
|
|
const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']);
|
|
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
|
|
|
|
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
|
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
|
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
|
expect(getSaved()?.automation).toMatchObject({
|
|
status: 'human-required',
|
|
handoffReason: 'Potential upsell detected (domain: fansly.com).',
|
|
});
|
|
expect(getSaved()?.sellerDetected).toBe(true);
|
|
});
|
|
|
|
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('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 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: 'reply', 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');
|
|
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).toContain('failed autonomous safety checks');
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|