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 () => {
+1 -1
View File
@@ -160,7 +160,7 @@ describe('Database Client', () => {
DROP TABLE conversation_automation_claims;
DROP TABLE ai_usage;
`);
db.pragma(`user_version = ${latestVersion - 4}`);
db.pragma(`user_version = ${latestVersion - 5}`);
runMigrations(db);
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
+19
View File
@@ -105,6 +105,25 @@ describe('RateLimiter', () => {
expect(order).toEqual([1, 2, 3]);
});
it('runs a queued high-priority reply before queued outreach', async () => {
const order: string[] = [];
let release!: () => void;
const blocker = new Promise<void>((resolve) => { release = resolve; });
const active = limiter.throttle(async () => {
order.push('active');
await blocker;
return 'active';
});
await vi.waitFor(() => expect(order).toEqual(['active']));
const outreach = limiter.throttle(async () => { order.push('outreach'); return 'outreach'; });
const reply = limiter.throttle(async () => { order.push('reply'); return 'reply'; }, () => true, undefined, 100);
release();
await Promise.all([active, outreach, reply]);
expect(order).toEqual(['active', 'reply', 'outreach']);
});
describe('getStats', () => {
it('returns initial stats', () => {
const stats = limiter.getStats();
+30
View File
@@ -220,6 +220,36 @@ describe('RedditAccountRuntimeManager', () => {
});
describe('account runtime isolation', () => {
it('does not accept new outreach while the account has actionable replies', async () => {
const stored = account('reply_first');
const hasActionableReplies = vi.fn().mockResolvedValue(true);
const runtime = new RedditAccountRuntime(
{
accountId: stored.accountId,
redditUsername: stored.redditUsername,
matrixUserId: stored.matrixUserId!,
cookies: [],
matrixCredentials: JSON.parse(stored.matrixCredentialsJson),
updatedAt: stored.updatedAt,
},
{
accountAuth: {
isFullyAuthenticated: () => true,
ensureFullyAuthenticated: async () => true,
},
conversationAutomation: { hasActionableReplies },
chatService: { getInvitationCooldown: () => null },
rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) },
} as unknown as RedditAccountRuntimeResources,
{ start: vi.fn(), stop: vi.fn() },
);
await runtime.start();
expect(await runtime.canAcceptPost()).toBe(false);
hasActionableReplies.mockResolvedValue(false);
expect(await runtime.canAcceptPost()).toBe(true);
});
it('uses the Reddit fullname for auth while retaining the Matrix runtime owner', () => {
expect(runtimeAuthenticatedAccount(
{ matrixUserId: '@t2_1u57iqxxbv:reddit.com' },
@@ -113,6 +113,21 @@ describe('account-bound domain repositories', () => {
expect(claimsA.markSent('same-room', 'same-event', 14)).toBe(false);
});
it('persists reply bodies and attempts so interrupted automation can resume', () => {
const claims = new ConversationAutomationClaimRepository(db, ACCOUNT_A);
expect(claims.tryClaim('room', 'event', 'durable reply', 50, 60)).toBe(true);
expect(claims.markAttempt('room', 'event', 70)).toBe(true);
expect(claims.getClaim('room', 'event')).toEqual({
conversationId: 'room', incomingKey: 'event', status: 'pending',
replyText: 'durable reply', incomingAt: 50, claimedAt: 60,
lastAttemptAt: 70, attemptCount: 1, sentAt: null,
});
expect(claims.listPending()).toEqual([expect.objectContaining({ incomingKey: 'event' })]);
expect(claims.releasePending('room', 'event')).toBe(true);
expect(claims.listPending()).toEqual([]);
});
it('claims recipients globally and blocks usernames with existing chat history', () => {
db.prepare(`
INSERT INTO reddit_conversations (