Add per-subreddit R4R conversation modes

This commit is contained in:
jjbrunton committed 2026-08-09 11:30:32 +01:00
1 parent 23d4c3a320
commit 0f76bcf59a
14 files changed
+343 -24

No files matched your search

+72 -2
View File
@@ -6,6 +6,7 @@ function harness(
sellerKeywords: string[] = [],
sellerDomains: string[] = [],
openerSentAt: number | null = 0,
subredditConversation?: { mode: 'legacy' | 'r4r' | 'manual'; maxAutomatedTurns: number },
) {
let saved: ConversationState | undefined;
const chat = {
@@ -13,7 +14,10 @@ function harness(
getCachedConversations: vi.fn().mockResolvedValue([]),
sendChatMessage: vi.fn().mockResolvedValue(true),
};
const ai = { generateMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?') };
const ai = {
generateMediaAsk: 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(() => saved),
save: vi.fn((_id: string, state: ConversationState) => { saved = structuredClone(state); }),
@@ -21,7 +25,7 @@ function harness(
const openers = {
getByChatRoomId: vi.fn(() => openerSentAt === null
? undefined
: { sentAt: openerSentAt }),
: { sentAt: openerSentAt, subreddit: 'r4r' }),
};
const claims = {
tryClaim: vi.fn(() => true),
@@ -34,6 +38,9 @@ function harness(
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,
}] : [],
);
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
}
@@ -148,4 +155,67 @@ describe('conversation automation', () => {
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: 1, 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' | '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');
});
});