Add hybrid R4R media conversation mode

This commit is contained in:
jjbrunton committed 2026-08-09 11:45:45 +01:00
1 parent 927636ec8b
commit b73e305cf3
15 files changed
+357 -35

No files matched your search

@@ -77,11 +77,11 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
}
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit, index) => (
subreddit.conversation.mode === 'r4r'
&& persistedDraft.subreddits[index]?.conversationMode !== 'r4r'
(subreddit.conversation.mode === 'r4r' || subreddit.conversation.mode === 'r4r-media')
&& persistedDraft.subreddits[index]?.conversationMode !== subreddit.conversation.mode
));
if (enablesAutonomousR4R) {
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. Existing conversations keep their snapshotted policy.');
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. The hybrid mode can make one optional image request after rapport. Existing conversations keep their snapshotted policy.');
}
if (reasons.length > 0) {
@@ -14,8 +14,9 @@ export interface SubredditDraft {
weight: string;
tone: z.infer<typeof ToneKeySchema>;
scripted: string;
conversationMode: 'legacy' | 'r4r' | 'manual';
conversationMode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
maxAutomatedTurns: string;
rapportTurnsBeforeMediaAsk: string;
}
export interface SettingsDraft {
@@ -77,6 +78,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
scripted: subreddit.scripted ?? '',
conversationMode: subreddit.conversation.mode,
maxAutomatedTurns: String(subreddit.conversation.maxAutomatedTurns),
rapportTurnsBeforeMediaAsk: String(subreddit.conversation.rapportTurnsBeforeMediaAsk),
})),
advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '',
};
@@ -93,9 +95,13 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
}
if (subreddit.conversationMode === 'r4r' && subreddit.maxAutomatedTurns.trim() === '') {
if ((subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media')
&& subreddit.maxAutomatedTurns.trim() === '') {
requiredErrors[`subreddits.${index}.maxAutomatedTurns`] = 'Maximum automated turns is required.';
}
if (subreddit.conversationMode === 'r4r-media' && subreddit.rapportTurnsBeforeMediaAsk.trim() === '') {
requiredErrors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] = 'Rapport turns is required.';
}
});
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
@@ -128,9 +134,12 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
conversation: {
mode: subreddit.conversationMode,
maxAutomatedTurns: subreddit.conversationMode === 'r4r'
maxAutomatedTurns: subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media'
? Number(subreddit.maxAutomatedTurns)
: 3,
rapportTurnsBeforeMediaAsk: subreddit.conversationMode === 'r4r-media'
? Number(subreddit.rapportTurnsBeforeMediaAsk)
: 3,
},
})),
});
@@ -138,7 +147,9 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const path = issue.path.join('.');
const pathParts = [...issue.path];
if (pathParts[0] === 'subreddits' && pathParts[2] === 'conversation') pathParts.splice(2, 1);
const path = pathParts.join('.');
if (!(path in errors)) errors[path] = issue.message;
}
return { errors };
@@ -122,7 +122,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
...draft,
subreddits: [
...draft.subreddits,
{ name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3' },
{ name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' },
],
})}
type="button"
@@ -202,15 +202,18 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
>
<option value="legacy">Legacy media asks</option>
<option value="r4r">Autonomous R4R conversation</option>
<option value="r4r-media">R4R then one media ask</option>
<option value="manual">Manual only</option>
</select>
<small>{subreddit.conversationMode === 'r4r'
? 'Continues general conversation autonomously. It will not request sexual media.'
: subreddit.conversationMode === 'r4r-media'
? 'Builds rapport autonomously, then makes one optional, non-explicit image request.'
: subreddit.conversationMode === 'manual'
? 'Incoming replies are marked for human handling and are never answered automatically.'
: 'Preserves the existing three-turn media-request flow.'}</small>
</label>
{subreddit.conversationMode === 'r4r' ? (
{subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media' ? (
<label className="settings-field">
<span>Maximum automated turns</span>
<input
@@ -227,6 +230,23 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
{errors[`subreddits.${index}.maxAutomatedTurns`] ? <small className="field-error" id={`subreddit-${index}-max-turns-error`}>{errors[`subreddits.${index}.maxAutomatedTurns`]}</small> : null}
</label>
) : null}
{subreddit.conversationMode === 'r4r-media' ? (
<label className="settings-field">
<span>Rapport turns before media ask</span>
<input
aria-describedby={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? `subreddit-${index}-rapport-turns-error` : undefined}
aria-invalid={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} rapport turns before media ask`}
max="19"
min="1"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, rapportTurnsBeforeMediaAsk: event.target.value }))}
step="1"
type="number"
value={subreddit.rapportTurnsBeforeMediaAsk}
/>
{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? <small className="field-error" id={`subreddit-${index}-rapport-turns-error`}>{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`]}</small> : null}
</label>
) : null}
<label className="settings-field scripted-field">
<span>Scripted message (optional)</span>
<textarea
+9
View File
@@ -141,6 +141,15 @@ Complete reference for every configuration option.
### Reddit Settings
Each subreddit also has a conversation behaviour in the dashboard:
- `legacy` preserves the existing three-turn media-request flow.
- `r4r` continues general conversation autonomously and never requests media.
- `r4r-media` continues general conversation for `rapportTurnsBeforeMediaAsk`, makes one optional non-explicit image request, and never repeats it.
- `manual` records the reply for human handling without sending an automated response.
`maxAutomatedTurns` is configurable for `r4r` and `r4r-media` up to 20. In hybrid mode, `rapportTurnsBeforeMediaAsk` must be lower than `maxAutomatedTurns`. A conversation snapshots these values when its first verified reply arrives, so later settings changes affect new conversations only.
```yaml
reddit:
# Subreddits to monitor for new posts
+11 -2
View File
@@ -26,9 +26,18 @@ export const RuntimeConfigSchema = z.strictObject({
reddit: RuntimeRedditConfigSchema,
});
export const ConversationBehaviourSchema = z.strictObject({
mode: z.union([z.literal('legacy'), z.literal('r4r'), z.literal('manual')]).default('legacy'),
mode: z.union([z.literal('legacy'), z.literal('r4r'), z.literal('r4r-media'), z.literal('manual')]).default('legacy'),
maxAutomatedTurns: z.number().int().min(1).max(20).default(3),
}).default({ mode: 'legacy', maxAutomatedTurns: 3 });
rapportTurnsBeforeMediaAsk: z.number().int().min(1).max(19).default(3),
}).superRefine((behaviour, context) => {
if (behaviour.mode === 'r4r-media' && behaviour.rapportTurnsBeforeMediaAsk >= behaviour.maxAutomatedTurns) {
context.addIssue({
code: 'custom',
path: ['rapportTurnsBeforeMediaAsk'],
message: 'Rapport turns must be lower than the maximum automated turns.',
});
}
}).default({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
export const SubredditConfigSchema = z.strictObject({
name: z.string(),
enabled: z.boolean().default(true),
@@ -149,7 +149,7 @@ describe('dashboard endpoint contracts', () => {
...payload,
subreddits: [{
...payload.subreddits[0],
conversation: { mode: 'legacy', maxAutomatedTurns: 3 },
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
}],
});
expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false);
+30
View File
@@ -5,6 +5,7 @@ import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-re
import {
formatConversationAsUntrustedData,
isSafeConversationReply,
isSafeHybridMediaAsk,
type ConversationIntent,
type ConversationState,
} from '../services/conversation-state.js';
@@ -377,6 +378,35 @@ Just write the message, nothing else.`;
}
}
async generateR4RMediaAsk(
conversationHistory: { sender: string; text: string; isSelf: boolean }[],
username: string,
state: ConversationState,
): Promise<string> {
try {
this.logger.debug(`Generating one-time R4R media ask for conversation with ${username}...`);
const intent: ConversationIntent = {
kind: 'media-ask',
reason: 'one optional image invitation after reciprocal rapport',
};
const prompt = this.buildConversationPrompt(conversationHistory, username, state, intent);
const message = await this.generateTextWithFallback('generateR4RMediaAsk', {
system: prompt.system,
user: `${prompt.user}\n\nWrite the single image invitation for this conversation. Acknowledge or answer their latest message first, then naturally ask whether they would be comfortable sharing one ordinary selfie, photo, or short video. Make it explicitly optional and easy to decline. Do not ask for nudity, explicit or sexual media, proof, contact details, or a meeting. Do not pressure, dare, challenge, bargain, guilt, or imply that you will ask again.`,
}, {
max_tokens: 300,
temperature: 0.85,
});
if (!isSafeHybridMediaAsk(message)) {
throw new Error('Generated R4R media ask failed hybrid output safeguards');
}
return message;
} catch (error) {
this.logger.error('R4R media-ask generation failed:', error);
throw error;
}
}
/**
* Check if message content contains upsell/spam keywords (OnlyFans, selling, etc.)
*/
+11 -2
View File
@@ -10,9 +10,18 @@ dotenv.config();
export const MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS = 20;
export const ConversationBehaviourSchema = z.object({
mode: z.enum(['legacy', 'r4r', 'manual']).default('legacy'),
mode: z.enum(['legacy', 'r4r', 'r4r-media', 'manual']).default('legacy'),
maxAutomatedTurns: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS).default(3),
}).default({ mode: 'legacy', maxAutomatedTurns: 3 });
rapportTurnsBeforeMediaAsk: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS - 1).default(3),
}).superRefine((behaviour, context) => {
if (behaviour.mode === 'r4r-media' && behaviour.rapportTurnsBeforeMediaAsk >= behaviour.maxAutomatedTurns) {
context.addIssue({
code: 'custom',
path: ['rapportTurnsBeforeMediaAsk'],
message: 'Rapport turns must be lower than the maximum automated turns.',
});
}
}).default({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
export type ConversationBehaviour = z.infer<typeof ConversationBehaviourSchema>;
+36 -10
View File
@@ -11,6 +11,7 @@ import {
chooseConversationIntent,
conversationMessageFingerprint,
createConversationAutomationState,
isSafeHybridMediaAsk,
isSafeR4RReply,
LEGACY_AUTOMATED_CONVERSATION_TURNS,
mergeConversationState,
@@ -43,7 +44,7 @@ export interface ConversationAutomationClaims {
/**
* Sends at most one reply for each newly received user message. Durable state
* makes retries and restarts idempotent. The opener subreddit selects a
* snapshotted legacy, autonomous R4R, or manual policy for the conversation.
* snapshotted legacy, autonomous R4R, hybrid R4R-to-media, or manual policy.
*/
export class ConversationAutomationService {
constructor(
@@ -96,10 +97,14 @@ export class ConversationAutomationService {
);
if (!savedState) {
const behaviour = this.resolveConversationBehaviour(opener.subreddit);
const maxTurns = behaviour.mode === 'r4r'
const maxTurns = behaviour.mode === 'r4r' || behaviour.mode === 'r4r-media'
? behaviour.maxAutomatedTurns
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
state.automation = createConversationAutomationState(behaviour.mode, maxTurns);
state.automation = createConversationAutomationState(
behaviour.mode,
maxTurns,
behaviour.rapportTurnsBeforeMediaAsk,
);
// Snapshot before generation so retries, restarts, dry runs, and later
// configuration edits cannot change the policy mid-conversation.
this.states.save(conversationId, state);
@@ -136,7 +141,7 @@ export class ConversationAutomationService {
if (state.boundaries.length > 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
}
if (state.automation.policyMode === 'r4r') {
if (state.automation.policyMode === 'r4r' || state.automation.policyMode === 'r4r-media') {
const safetyReason = r4rSafetyHandoffReason(latest.text);
if (safetyReason) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
@@ -154,14 +159,26 @@ export class ConversationAutomationService {
}
const isLegacy = state.automation.policyMode === 'legacy';
const intent = chooseConversationIntent(mapped, state, isLegacy ? 'media-ask' : undefined);
const isHybrid = state.automation.policyMode === 'r4r-media';
const isHybridMediaAsk = isHybrid
&& !state.askedForMedia
&& state.automation.turnsUsed >= state.automation.rapportTurnsBeforeMediaAsk;
if (isHybridMediaAsk) {
state.automation.hybridPhase = 'media-ask';
this.states.save(conversationId, state);
}
const requestsMedia = isLegacy || isHybridMediaAsk;
const intent = chooseConversationIntent(mapped, state, requestsMedia ? 'media-ask' : undefined);
if (isLegacy && intent.kind !== 'media-ask') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
}
const reply = isLegacy
? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
: await this.ai.generateReply(mapped, conversation.username, state, intent);
if (!isLegacy && !isSafeR4RReply(reply)) {
: isHybridMediaAsk
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
: await this.ai.generateReply(mapped, conversation.username, state, intent);
const safeReply = isHybridMediaAsk ? isSafeHybridMediaAsk(reply) : isLegacy || isSafeR4RReply(reply);
if (!safeReply) {
return this.finish(
conversation,
conversationId,
@@ -181,11 +198,16 @@ export class ConversationAutomationService {
// Persist the full intended state before the remote send. If the process
// dies after Matrix accepts the message, both this state and the durable
// event claim make the retry fail closed instead of sending twice.
if (isLegacy) state.askedForMedia = true;
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true;
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey;
state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
const exhausted = state.automation.turnsUsed >= state.automation.maxAutomatedTurns;
// Keep the hybrid conversation active after its one media ask even when
// that send consumes the final turn. The next inbound event still needs
// to be classified as an image, a decline, or a normal turn-limit handoff.
const exhausted = state.automation.turnsUsed >= state.automation.maxAutomatedTurns
&& !isHybridMediaAsk;
if (exhausted) {
state.automation.status = 'human-required';
state.automation.handoffReason = `${state.automation.maxAutomatedTurns} automated turns have been used.`;
@@ -214,7 +236,11 @@ export class ConversationAutomationService {
const configured = subreddit
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
: undefined;
return configured?.conversation ?? { mode: 'legacy', maxAutomatedTurns: LEGACY_AUTOMATED_CONVERSATION_TURNS };
return configured?.conversation ?? {
mode: 'legacy',
maxAutomatedTurns: LEGACY_AUTOMATED_CONVERSATION_TURNS,
rapportTurnsBeforeMediaAsk: LEGACY_AUTOMATED_CONVERSATION_TURNS,
};
}
private finish(
+30 -3
View File
@@ -34,8 +34,10 @@ export interface ConversationState {
}
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 1;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'manual';
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 2;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
export type ConversationAutomationStatus =
| 'active'
@@ -48,6 +50,8 @@ export interface ConversationAutomationState {
policyMode: ConversationPolicyMode;
policyVersion: number;
maxAutomatedTurns: number;
rapportTurnsBeforeMediaAsk: number;
hybridPhase: HybridConversationPhase | null;
turnsUsed: number;
status: ConversationAutomationStatus;
lastIncomingKey: string | null;
@@ -59,11 +63,14 @@ export interface ConversationAutomationState {
export function createConversationAutomationState(
policyMode: ConversationPolicyMode = 'legacy',
maxAutomatedTurns = LEGACY_AUTOMATED_CONVERSATION_TURNS,
rapportTurnsBeforeMediaAsk = DEFAULT_HYBRID_RAPPORT_TURNS,
): ConversationAutomationState {
return {
policyMode,
policyVersion: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
rapportTurnsBeforeMediaAsk,
hybridPhase: policyMode === 'r4r-media' ? 'rapport' : null,
turnsUsed: 0,
status: 'active',
lastIncomingKey: null,
@@ -205,6 +212,14 @@ export function isSafeR4RReply(message: string): boolean {
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
}
/** The hybrid policy gets one optional, non-explicit image request after rapport. */
export function isSafeHybridMediaAsk(message: string): boolean {
if (!isSafeConversationReply(message)) return false;
if (!/\b(pic|pics|photo|photos|selfie|selfies|video|videos)\b/i.test(message)) return false;
if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false;
return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message);
}
export function serializeConversationState(state: ConversationState): string {
return JSON.stringify(state);
}
@@ -238,7 +253,9 @@ export function parseConversationState(value: string | null | undefined): Conver
function parseAutomationState(value: unknown): ConversationAutomationState {
if (!value || typeof value !== 'object') return createConversationAutomationState();
const parsed = value as Partial<ConversationAutomationState>;
const policyMode: ConversationPolicyMode = parsed.policyMode === 'r4r' || parsed.policyMode === 'manual'
const policyMode: ConversationPolicyMode = parsed.policyMode === 'r4r'
|| parsed.policyMode === 'r4r-media'
|| parsed.policyMode === 'manual'
? parsed.policyMode
: 'legacy';
const maxAutomatedTurns = typeof parsed.maxAutomatedTurns === 'number'
@@ -248,12 +265,22 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden'
? parsed.status
: 'active';
const rapportTurnsBeforeMediaAsk = typeof parsed.rapportTurnsBeforeMediaAsk === 'number'
? Math.max(1, Math.min(19, Math.trunc(parsed.rapportTurnsBeforeMediaAsk)))
: DEFAULT_HYBRID_RAPPORT_TURNS;
const hybridPhase: HybridConversationPhase | null = policyMode !== 'r4r-media'
? null
: parsed.hybridPhase === 'media-ask' || parsed.hybridPhase === 'post-media-ask'
? parsed.hybridPhase
: 'rapport';
return {
policyMode,
policyVersion: typeof parsed.policyVersion === 'number' && parsed.policyVersion > 0
? Math.trunc(parsed.policyVersion)
: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
rapportTurnsBeforeMediaAsk,
hybridPhase,
turnsUsed: typeof parsed.turnsUsed === 'number'
? Math.max(0, Math.min(maxAutomatedTurns, Math.trunc(parsed.turnsUsed)))
: 0,
+17 -1
View File
@@ -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');
+83 -4
View File
@@ -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);
});
});
+12 -1
View File
@@ -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);
});
});
+42 -2
View File
@@ -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,
}));
});
+35
View File
@@ -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();