Add per-subreddit R4R conversation modes
This commit is contained in:
1 parent
23d4c3a320
commit
0f76bcf59a
14 files changed
+343
-24
No files matched your search
+27
-1
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user