Pause automation after manual Reddit replies
This commit is contained in:
1 parent
bf474dfe10
commit
84a133451f
5 files changed
+175
-6
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user