Prioritize conversation replies
This commit is contained in:
1 parent
f55ae7ddf8
commit
9b86cee9be
13 files changed
+535
-39
No files matched your search
@@ -12,7 +12,8 @@ function harness(
|
||||
rapportTurnsBeforeMediaAsk?: number;
|
||||
},
|
||||
) {
|
||||
let saved: ConversationState | undefined;
|
||||
const saved = new Map<string, ConversationState>();
|
||||
let lastSaved: ConversationState | undefined;
|
||||
const chat = {
|
||||
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
||||
getCachedConversations: vi.fn().mockResolvedValue([]),
|
||||
@@ -24,8 +25,11 @@ function harness(
|
||||
generateReply: vi.fn().mockResolvedValue('That sounds fun. What got you into it?'),
|
||||
};
|
||||
const states = {
|
||||
get: vi.fn(() => saved),
|
||||
save: vi.fn((_id: string, state: ConversationState) => { saved = structuredClone(state); }),
|
||||
get: vi.fn((id: string) => saved.get(id)),
|
||||
save: vi.fn((id: string, state: ConversationState) => {
|
||||
lastSaved = structuredClone(state);
|
||||
saved.set(id, lastSaved);
|
||||
}),
|
||||
};
|
||||
const openers = {
|
||||
getByChatRoomId: vi.fn(() => openerSentAt === null
|
||||
@@ -34,6 +38,10 @@ function harness(
|
||||
};
|
||||
const claims = {
|
||||
tryClaim: vi.fn(() => true),
|
||||
getClaim: vi.fn(() => undefined),
|
||||
listPending: vi.fn(() => []),
|
||||
markAttempt: vi.fn(() => true),
|
||||
releasePending: vi.fn(() => true),
|
||||
markSent: vi.fn(() => true),
|
||||
};
|
||||
const rateLimiter = {
|
||||
@@ -49,8 +57,10 @@ function harness(
|
||||
rapportTurnsBeforeMediaAsk: subredditConversation.rapportTurnsBeforeMediaAsk ?? 3,
|
||||
},
|
||||
}] : [],
|
||||
() => 1_000,
|
||||
0,
|
||||
);
|
||||
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
|
||||
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
|
||||
}
|
||||
|
||||
function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) {
|
||||
@@ -151,10 +161,118 @@ describe('conversation automation', () => {
|
||||
|
||||
await service.processConversation(incoming(1));
|
||||
|
||||
expect(claims.tryClaim).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
expect(claims.tryClaim).toHaveBeenCalledWith(
|
||||
'room-alice',
|
||||
'in-1',
|
||||
'Would you be comfortable sharing a selfie?',
|
||||
3,
|
||||
);
|
||||
expect(claims.tryClaim.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
|
||||
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
expect(claims.markAttempt).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
});
|
||||
|
||||
it('refreshes a wider reply window and drains actionable conversations oldest first', async () => {
|
||||
const { service, chat } = harness();
|
||||
const newer = { ...incoming(2), username: 'newer', roomId: 'room-newer', url: '/room/newer' };
|
||||
const older = { ...incoming(1), username: 'older', roomId: 'room-older', url: '/room/older' };
|
||||
chat.getCachedConversations.mockResolvedValue([newer, older]);
|
||||
|
||||
await service.processUnreadConversations();
|
||||
|
||||
expect(chat.refreshTopChats).toHaveBeenCalledWith(25);
|
||||
expect(chat.sendChatMessage.mock.calls.map(([url]) => url)).toEqual(['/room/older', '/room/newer']);
|
||||
});
|
||||
|
||||
it('blocks outreach while an actionable reply exists and clears after processing', async () => {
|
||||
const { service, chat } = harness();
|
||||
chat.getCachedConversations.mockResolvedValue([incoming(1)]);
|
||||
|
||||
expect(await service.hasActionableReplies()).toBe(true);
|
||||
await service.processUnreadConversations();
|
||||
|
||||
expect(await service.hasActionableReplies()).toBe(false);
|
||||
expect(service.getPriorityStatus()).toMatchObject({
|
||||
processing: false,
|
||||
actionableBacklog: 0,
|
||||
lastScanAt: 1_000,
|
||||
lastReplySentAt: 1_000,
|
||||
lastReplyLatencyMs: 997,
|
||||
});
|
||||
});
|
||||
|
||||
it('submits replies to the rate limiter at high priority', async () => {
|
||||
const { service, rateLimiter } = harness();
|
||||
|
||||
await service.processConversation(incoming(1));
|
||||
|
||||
expect(rateLimiter.throttle).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('retries the same durable reply after an interrupted send', async () => {
|
||||
const { service, chat, ai, claims } = harness();
|
||||
chat.sendChatMessage.mockRejectedValueOnce(new Error('interrupted'));
|
||||
await expect(service.processConversation(incoming(1))).rejects.toThrow('interrupted');
|
||||
claims.getClaim.mockReturnValue({
|
||||
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending',
|
||||
replyText: 'Would you be comfortable sharing a selfie?', incomingAt: 3,
|
||||
claimedAt: 0, lastAttemptAt: 10, attemptCount: 1, sentAt: null,
|
||||
} as never);
|
||||
chat.sendChatMessage.mockResolvedValue(true);
|
||||
|
||||
const result = await service.processConversation(incoming(1));
|
||||
|
||||
expect(result?.kind).toBe('sent');
|
||||
expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1);
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
|
||||
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
});
|
||||
|
||||
it('hands legacy pending claims without a stored reply to a human without blocking outreach forever', async () => {
|
||||
const { service, chat, claims, getSaved } = harness();
|
||||
claims.getClaim.mockReturnValue({
|
||||
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending',
|
||||
replyText: null, incomingAt: null, claimedAt: 0,
|
||||
lastAttemptAt: null, attemptCount: 0, sentAt: null,
|
||||
} as never);
|
||||
chat.getCachedConversations.mockResolvedValue([incoming(1)]);
|
||||
|
||||
const result = await service.processConversation(incoming(1));
|
||||
|
||||
expect(result?.kind).toBe('human-required');
|
||||
expect(claims.releasePending).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
expect(getSaved()?.automation.handoffReason).toContain('no recoverable message body');
|
||||
claims.getClaim.mockReturnValue(undefined);
|
||||
expect(await service.hasActionableReplies()).toBe(false);
|
||||
});
|
||||
|
||||
it('reconciles an interrupted claim when Matrix already contains the reply', async () => {
|
||||
const { service, chat, claims } = harness();
|
||||
const conversation = {
|
||||
...incoming(1), unread: false, isFromUs: true, lastMessageTime: 4,
|
||||
messages: [
|
||||
...incoming(1).messages,
|
||||
{ eventId: 'out-reply', sender: 'me', text: 'reply', timestamp: 4, isSelf: true },
|
||||
],
|
||||
};
|
||||
const pending = {
|
||||
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending' as const,
|
||||
replyText: 'Would you be comfortable sharing a selfie?', incomingAt: 3,
|
||||
claimedAt: 0, lastAttemptAt: 10, attemptCount: 1, sentAt: null,
|
||||
};
|
||||
claims.listPending.mockReturnValue([pending] as never);
|
||||
chat.getCachedConversations.mockResolvedValue([conversation]);
|
||||
|
||||
await service.processUnreadConversations();
|
||||
|
||||
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send when another worker already claimed the inbound event', async () => {
|
||||
|
||||
Reference in new issue
Block a user