Prioritize conversation replies

This commit is contained in:
jjbrunton committed 2026-08-09 16:04:39 +01:00
1 parent f55ae7ddf8
commit 9b86cee9be
13 files changed
+535 -39

No files matched your search

+123 -5
View File
@@ -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 () => {