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

+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);
});
});