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 () => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user