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

+27 -1
View File
@@ -55,6 +55,29 @@ describe('ConfigSchema', () => {
if (result.success) expect(result.data.reddit.subreddits[0].enabled).toBe(true);
});
it('defaults existing subreddit entries to legacy conversation behaviour', () => {
const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reddit.subreddits[0].conversation).toEqual({
mode: 'legacy',
maxAutomatedTurns: 3,
});
}
});
it('accepts r4r and manual behaviour while enforcing the autonomous turn limit', () => {
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{ name: 'r4r', conversation: { mode: 'r4r', maxAutomatedTurns: 12 } }] },
})).success).toBe(true);
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{ name: 'r4r', conversation: { mode: 'manual' } }] },
})).success).toBe(true);
expect(ConfigSchema.safeParse(minimalConfig({
reddit: { subreddits: [{ name: 'r4r', conversation: { mode: 'r4r', maxAutomatedTurns: 21 } }] },
})).success).toBe(false);
});
it('accepts valid tone values', () => {
for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) {
const result = ConfigSchema.safeParse({
@@ -351,7 +374,10 @@ openrouter:
`);
const config = loadConfig();
expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', enabled: true, type: 'image', tone: 'default' }]);
expect(config.reddit.subreddits).toEqual([{
name: 'test-sub', enabled: true, type: 'image', tone: 'default',
conversation: { mode: 'legacy', maxAutomatedTurns: 3 },
}]);
expect(config.reddit.pollIntervalMs).toBe(5000);
expect(config.openrouter.apiKey).toBe('yaml-key');
});
+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');
});
});
+7
View File
@@ -5,6 +5,7 @@ import {
formatConversationAsUntrustedData,
mergeConversationState,
isSafeConversationReply,
isSafeR4RReply,
parseConversationState,
remainingAutomationTurns,
type ConversationMessage,
@@ -71,6 +72,7 @@ describe('conversation state', () => {
}));
expect(state?.automation).toMatchObject({ turnsUsed: 0, status: 'active' });
expect(state?.automation).toMatchObject({ policyMode: 'legacy', policyVersion: 1, maxAutomatedTurns: 3 });
});
it('treats conversation content as untrusted data rather than instructions', () => {
@@ -99,4 +101,9 @@ describe('conversation state', () => {
expect(isSafeConversationReply('One. Two. Three. Four.')).toBe(false);
expect(isSafeConversationReply('That sounds fun, what did you have in mind?')).toBe(true);
});
it('rejects media solicitation from autonomous r4r replies', () => {
expect(isSafeR4RReply('Would you send me a nude?')).toBe(false);
expect(isSafeR4RReply('I love live music too. What was your last gig?')).toBe(true);
});
});
+34
View File
@@ -229,6 +229,40 @@ describe('settings and access', () => {
expect(updateRequests).toBe(1);
});
it('configures autonomous r4r per subreddit and requires explicit confirmation', async () => {
installConfigApi();
let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number } }> } | undefined;
server.use(http.put('/api/config', async ({ request }) => {
requestBody = await request.json() as typeof requestBody;
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
subreddits: requestBody?.subreddits ?? config.subreddits,
});
}));
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
const behaviour = await screen.findByRole('combobox', { name: 'Subreddit 1 conversation behaviour' });
expect(behaviour).toHaveValue('legacy');
expect(screen.queryByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' })).not.toBeInTheDocument();
await user.selectOptions(behaviour, 'r4r');
const maxTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' });
await user.clear(maxTurns);
await user.type(maxTurns, '7');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval');
expect(requestBody).toBeUndefined();
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
mode: 'r4r', maxAutomatedTurns: 7,
}));
});
it('validates advanced application JSON before saving', async () => {
installConfigApi();
let updateRequests = 0;