Add hybrid R4R media conversation mode
This commit is contained in:
1 parent
927636ec8b
commit
b73e305cf3
15 files changed
+357
-35
No files matched your search
+17
-1
@@ -62,6 +62,7 @@ describe('ConfigSchema', () => {
|
||||
expect(result.data.reddit.subreddits[0].conversation).toEqual({
|
||||
mode: 'legacy',
|
||||
maxAutomatedTurns: 3,
|
||||
rapportTurnsBeforeMediaAsk: 3,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -78,6 +79,21 @@ describe('ConfigSchema', () => {
|
||||
})).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the hybrid mode only when rapport completes before the turn limit', () => {
|
||||
expect(ConfigSchema.safeParse(minimalConfig({
|
||||
reddit: { subreddits: [{
|
||||
name: 'r4r',
|
||||
conversation: { mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3 },
|
||||
}] },
|
||||
})).success).toBe(true);
|
||||
expect(ConfigSchema.safeParse(minimalConfig({
|
||||
reddit: { subreddits: [{
|
||||
name: 'r4r',
|
||||
conversation: { mode: 'r4r-media', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||
}] },
|
||||
})).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({
|
||||
@@ -376,7 +392,7 @@ openrouter:
|
||||
const config = loadConfig();
|
||||
expect(config.reddit.subreddits).toEqual([{
|
||||
name: 'test-sub', enabled: true, type: 'image', tone: 'default',
|
||||
conversation: { mode: 'legacy', maxAutomatedTurns: 3 },
|
||||
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||
}]);
|
||||
expect(config.reddit.pollIntervalMs).toBe(5000);
|
||||
expect(config.openrouter.apiKey).toBe('yaml-key');
|
||||
|
||||
@@ -6,7 +6,11 @@ function harness(
|
||||
sellerKeywords: string[] = [],
|
||||
sellerDomains: string[] = [],
|
||||
openerSentAt: number | null = 0,
|
||||
subredditConversation?: { mode: 'legacy' | 'r4r' | 'manual'; maxAutomatedTurns: number },
|
||||
subredditConversation?: {
|
||||
mode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||
maxAutomatedTurns: number;
|
||||
rapportTurnsBeforeMediaAsk?: number;
|
||||
},
|
||||
) {
|
||||
let saved: ConversationState | undefined;
|
||||
const chat = {
|
||||
@@ -16,6 +20,7 @@ function harness(
|
||||
};
|
||||
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 = {
|
||||
@@ -39,7 +44,10 @@ function harness(
|
||||
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,
|
||||
name: 'r4r', enabled: true, type: 'chat', tone: 'default', conversation: {
|
||||
...subredditConversation,
|
||||
rapportTurnsBeforeMediaAsk: subredditConversation.rapportTurnsBeforeMediaAsk ?? 3,
|
||||
},
|
||||
}] : [],
|
||||
);
|
||||
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
|
||||
@@ -167,13 +175,16 @@ describe('conversation automation', () => {
|
||||
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
|
||||
expect(getSaved()?.automation).toMatchObject({
|
||||
policyMode: 'r4r', policyVersion: 1, maxAutomatedTurns: 2, turnsUsed: 2,
|
||||
policyMode: 'r4r', policyVersion: 2, 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 } = {
|
||||
const behaviour: {
|
||||
mode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||
maxAutomatedTurns: number;
|
||||
} = {
|
||||
mode: 'r4r', maxAutomatedTurns: 2,
|
||||
};
|
||||
const { service, ai, getSaved } = harness([], [], 0, behaviour);
|
||||
@@ -218,4 +229,72 @@ describe('conversation automation', () => {
|
||||
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: 2,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatConversationAsUntrustedData,
|
||||
mergeConversationState,
|
||||
isSafeConversationReply,
|
||||
isSafeHybridMediaAsk,
|
||||
isSafeR4RReply,
|
||||
parseConversationState,
|
||||
remainingAutomationTurns,
|
||||
@@ -72,7 +73,10 @@ describe('conversation state', () => {
|
||||
}));
|
||||
|
||||
expect(state?.automation).toMatchObject({ turnsUsed: 0, status: 'active' });
|
||||
expect(state?.automation).toMatchObject({ policyMode: 'legacy', policyVersion: 1, maxAutomatedTurns: 3 });
|
||||
expect(state?.automation).toMatchObject({
|
||||
policyMode: 'legacy', policyVersion: 2, maxAutomatedTurns: 3,
|
||||
rapportTurnsBeforeMediaAsk: 3, hybridPhase: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats conversation content as untrusted data rather than instructions', () => {
|
||||
@@ -106,4 +110,11 @@ describe('conversation state', () => {
|
||||
expect(isSafeR4RReply('Would you send me a nude?')).toBe(false);
|
||||
expect(isSafeR4RReply('I love live music too. What was your last gig?')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows only optional non-explicit media asks in the hybrid phase', () => {
|
||||
expect(isSafeHybridMediaAsk('Would you be comfortable sharing a selfie?')).toBe(true);
|
||||
expect(isSafeHybridMediaAsk('Would you send me a nude?')).toBe(false);
|
||||
expect(isSafeHybridMediaAsk('You have to prove it by sending a photo.')).toBe(false);
|
||||
expect(isSafeHybridMediaAsk('What music are you into?')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -231,7 +231,7 @@ describe('settings and access', () => {
|
||||
|
||||
it('configures autonomous r4r per subreddit and requires explicit confirmation', async () => {
|
||||
installConfigApi();
|
||||
let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number } }> } | undefined;
|
||||
let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number } }> } | undefined;
|
||||
server.use(http.put('/api/config', async ({ request }) => {
|
||||
requestBody = await request.json() as typeof requestBody;
|
||||
return HttpResponse.json({
|
||||
@@ -259,7 +259,47 @@ describe('settings and access', () => {
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
||||
|
||||
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
|
||||
mode: 'r4r', maxAutomatedTurns: 7,
|
||||
mode: 'r4r', maxAutomatedTurns: 7, rapportTurnsBeforeMediaAsk: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
it('configures rapport and total turns for the autonomous hybrid mode', async () => {
|
||||
installConfigApi();
|
||||
let requestBody: {
|
||||
subreddits: Array<{
|
||||
conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: 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' });
|
||||
await user.selectOptions(behaviour, 'r4r-media');
|
||||
const maxTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' });
|
||||
const rapportTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 rapport turns before media ask' });
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
expect(screen.getByText('Rapport turns must be lower than the maximum automated turns.')).toBeInTheDocument();
|
||||
expect(requestBody).toBeUndefined();
|
||||
await user.clear(maxTurns);
|
||||
await user.type(maxTurns, '6');
|
||||
await user.clear(rapportTurns);
|
||||
await user.type(rapportTurns, '3');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
|
||||
expect(dialog).toHaveTextContent('hybrid mode can make one optional image request after rapport');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
||||
|
||||
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
|
||||
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type OpenAI from 'openai';
|
||||
import { OpenRouterClient, stripDashes } from '../src/ai/openrouter.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
import { buildConversationState, createConversationAutomationState } from '../src/services/conversation-state.js';
|
||||
|
||||
describe('OpenRouterClient', () => {
|
||||
let client: OpenRouterClient;
|
||||
@@ -728,6 +729,40 @@ describe('OpenRouterClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateR4RMediaAsk', () => {
|
||||
it('requests one optional non-explicit image after rapport', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'You sound lovely, would you be comfortable sharing a selfie?' } }],
|
||||
});
|
||||
const state = buildConversationState([
|
||||
{ sender: 'me', text: '32M UK, fancy a chat?', isSelf: true },
|
||||
{ sender: 'them', text: 'I love live music.', isSelf: false },
|
||||
]);
|
||||
state.automation = createConversationAutomationState('r4r-media', 6, 3);
|
||||
state.automation.turnsUsed = 3;
|
||||
|
||||
const result = await client.generateR4RMediaAsk(
|
||||
[{ sender: 'them', text: 'I love live music.', isSelf: false }],
|
||||
'them',
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toContain('comfortable sharing a selfie');
|
||||
const prompt = mockCreate.mock.calls[0][0].messages[1].content as string;
|
||||
expect(prompt).toContain('single image invitation');
|
||||
expect(prompt).toContain('Do not ask for nudity');
|
||||
});
|
||||
|
||||
it('rejects an explicit media request', async () => {
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: 'Send me a nude.' } }] });
|
||||
const state = buildConversationState([]);
|
||||
state.automation = createConversationAutomationState('r4r-media', 6, 3);
|
||||
|
||||
await expect(client.generateR4RMediaAsk([], 'them', state))
|
||||
.rejects.toThrow('hybrid output safeguards');
|
||||
});
|
||||
});
|
||||
|
||||
it('records model, token, cost, latency, and outcome for each request attempt', async () => {
|
||||
const usageRecorder = vi.fn();
|
||||
const config = getTestConfig();
|
||||
|
||||
Reference in new issue
Block a user