Pause automation after manual Reddit replies

This commit is contained in:
jjbrunton committed 2026-08-09 19:30:16 +01:00
1 parent bf474dfe10
commit 84a133451f
5 files changed
+175 -6

No files matched your search

+100 -3
View File
@@ -13,7 +13,11 @@ function harness(
rapportTurnsBeforeMediaAsk?: number;
},
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
mediaExchange?: { detect(conversationId: string, text: string): string | null; handle(conversationId: string, incomingKey: string, text: string): Promise<object> },
mediaExchange?: {
detect(conversationId: string, text: string): string | null;
handle(conversationId: string, incomingKey: string, text: string): Promise<object>;
getOutboundAttribution?(conversationId: string): { eventIds: string[]; texts: string[] };
},
) {
const saved = new Map<string, ConversationState>();
let lastSaved: ConversationState | undefined;
@@ -37,7 +41,7 @@ function harness(
const openers = {
getByChatRoomId: vi.fn(() => openerSentAt === null
? undefined
: { sentAt: openerSentAt, subreddit: 'r4r' }),
: { sentAt: openerSentAt, subreddit: 'r4r', message: 'hello' }),
};
const claims = {
tryClaim: vi.fn(() => true),
@@ -123,6 +127,11 @@ describe('conversation automation', () => {
const fixture = harness();
const conversation = incoming(1, 'Sure');
conversation.messages[0].text = 'If you feel like it, send a pic where you are smiling.';
fixture.openers.getByChatRoomId.mockReturnValue({
sentAt: 0,
subreddit: 'r4r',
message: 'If you feel like it, send a pic where you are smiling.',
});
const result = await fixture.service.processConversation(conversation);
@@ -159,6 +168,11 @@ describe('conversation automation', () => {
],
};
const fixture = harness([], [], 0, undefined, async () => latest);
fixture.openers.getByChatRoomId.mockReturnValue({
sentAt: 0,
subreddit: 'r4r',
message: 'Send a pic where you are smiling.',
});
fixture.claims.getClaim.mockReturnValue({
conversationId: 'room-alice', incomingKey: 'tattoo-event', status: 'pending',
replyText: 'If you ever feel like sharing a quick snap, I am all eyes.', incomingAt: 30,
@@ -216,6 +230,89 @@ describe('conversation automation', () => {
expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1);
});
it('persists a human override when a direct Reddit message appears before the next inbound reply', async () => {
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 });
const conversation = incoming(2, 'recipient follow-up');
conversation.messages = [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'first-inbound', sender: 'alice', text: 'first reply', timestamp: 2, isSelf: false },
{ eventId: 'manual', sender: 'me', text: 'operator reply', timestamp: 3, isSelf: true },
{ eventId: 'next-inbound', sender: 'alice', text: 'recipient follow-up', timestamp: 4, isSelf: false },
];
expect(await fixture.service.processConversation(conversation)).toBeNull();
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.ai.generateReply).not.toHaveBeenCalled();
expect(fixture.getSaved()?.automation).toMatchObject({
status: 'user-overridden',
handoffReason: 'A manual reply was observed in this conversation.',
});
});
it('does not mistake a recorded automated reply for an operator message', async () => {
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 });
const first = await fixture.service.processConversation(incoming(1));
expect(first?.kind).toBe('sent');
const conversation = incoming(2, 'second reply');
conversation.messages = [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'first-inbound', sender: 'alice', text: 'first reply', timestamp: 2, isSelf: false },
{ eventId: 'automated', sender: 'me', text: 'That sounds fun. What got you into it?', timestamp: 3, isSelf: true },
{ eventId: 'second-inbound', sender: 'alice', text: 'second reply', timestamp: 4, isSelf: false },
];
expect((await fixture.service.processConversation(conversation))?.kind).toBe('sent');
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(2);
expect(fixture.getSaved()?.automation.status).toBe('active');
});
it('does not mistake confirmed exchange-library sends for operator messages', async () => {
const reciprocal = 'Your turn — send me one back if you’re comfortable.';
const mediaExchange = {
detect: vi.fn().mockReturnValue(null),
handle: vi.fn(),
getOutboundAttribution: vi.fn().mockReturnValue({
eventIds: ['library-image'],
texts: [reciprocal],
}),
};
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, mediaExchange);
const conversation = incoming(2, 'thanks');
conversation.messages = [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'request', sender: 'alice', text: 'send a pic', timestamp: 2, isSelf: false },
{ eventId: 'library-image', sender: 'me', text: 'image.jpg', timestamp: 3, isSelf: true },
{ eventId: 'library-text', sender: 'me', text: reciprocal, timestamp: 4, isSelf: true },
{ eventId: 'thanks', sender: 'alice', text: 'thanks', timestamp: 5, isSelf: false },
];
expect((await fixture.service.processConversation(conversation))?.kind).toBe('sent');
expect(fixture.getSaved()?.automation.status).toBe('active');
});
it('supersedes a pending reply when Matrix contains a different operator-authored message', async () => {
const fixture = harness();
fixture.claims.listPending.mockReturnValue([{
conversationId: 'room-alice', incomingKey: 'first-inbound', status: 'pending',
replyText: 'Expected automated reply', incomingAt: 2, claimedAt: 2,
lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
}] as never);
fixture.chat.getCachedConversations.mockResolvedValue([{
...incoming(2),
messages: [
{ eventId: 'opener', sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
{ eventId: 'first-inbound', sender: 'alice', text: 'first reply', timestamp: 2, isSelf: false },
{ eventId: 'manual', sender: 'me', text: 'Different operator reply', timestamp: 3, isSelf: true },
],
}]);
await fixture.service.processPendingConversations();
expect(fixture.claims.markSent).not.toHaveBeenCalled();
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'first-inbound');
});
it('does not reply when the room has no recorded opener', async () => {
const { service, chat, ai, claims } = harness([], [], null);
@@ -365,7 +462,7 @@ describe('conversation automation', () => {
...incoming(1), unread: false, isFromUs: true, lastMessageTime: 4,
messages: [
...incoming(1).messages,
{ eventId: 'out-reply', sender: 'me', text: 'reply', timestamp: 4, isSelf: true },
{ eventId: 'out-reply', sender: 'me', text: 'Would you be comfortable sharing a selfie?', timestamp: 4, isSelf: true },
],
};
const pending = {
+4
View File
@@ -58,6 +58,10 @@ describe('MediaLibraryExchangeService', () => {
expect(new Set(sentAssets).size).toBe(sentAssets.length);
expect(chat.sendChatImage.mock.invocationCallOrder[0]).toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
expect(repository.get('penis')).toMatchObject({ sendCount: 1, lastSentAt: expect.any(Number) });
expect(service.getOutboundAttribution('room')).toEqual({
eventIds: ['$1', '$2', '$3'],
texts: Array(3).fill('Your turn — send me one back if you’re comfortable.'),
});
db.close();
});