Make conversation queues mutually exclusive

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:25:45 +01:00
1 parent 3bb1dda6bb
commit 2447563c60
20 files changed
+386 -220

No files matched your search

+41
View File
@@ -223,6 +223,32 @@ describe('conversation automation', () => {
});
});
it('kills instead of resuming a queued reply when an off-platform push arrives', async () => {
const observed = incoming(1, 'how is your week going?');
const latest: ActiveChat = {
...observed,
lastMessageTime: 40,
lastMessageSnippet: 'add me on telegram instead',
messages: [
...observed.messages!,
{ eventId: 'telegram-event', sender: 'alice', text: 'add me on telegram instead', timestamp: 40, isSelf: false },
],
};
const fixture = harness([], [], 0, undefined, async () => latest);
fixture.claims.getClaim.mockReturnValue({
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending',
replyText: 'Mine has been good, thanks.', incomingAt: 3,
claimedAt: 0, lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
} as never);
const result = await fixture.service.processConversation(observed);
expect(result?.kind).toBe('killed');
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'in-1');
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.getSaved()).toMatchObject({ sellerDetected: true, automation: { status: 'killed' } });
});
it('kills a conversation that pushes a seller funnel', async () => {
const { service, chat, ai, triage, getSaved } = harness(['onlyfans'], ['fansly.com']);
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
@@ -619,6 +645,21 @@ describe('conversation automation', () => {
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
it('automatically ends a historical read conversation with an off-platform push', async () => {
const { service, chat, triage, getSaved } = harness();
chat.getCachedConversations.mockResolvedValue([{
...incoming(1, 'add me on telegram, I barely use reddit'),
unread: false,
}]);
const results = await service.processPendingConversations();
expect(results).toEqual([expect.objectContaining({ kind: 'killed', conversationId: 'room-alice' })]);
expect(getSaved()).toMatchObject({ sellerDetected: true, automation: { status: 'killed' } });
expect(triage.setStatus).toHaveBeenCalledWith('room-alice', 'done', null, 1_000);
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
it('retries the same durable reply after an interrupted send', async () => {
const { service, chat, ai, claims } = harness();
chat.sendChatMessage.mockRejectedValueOnce(new Error('interrupted'));
+38 -52
View File
@@ -3,6 +3,7 @@ import type { ActiveChat } from '../packages/api-contracts/src/index.js';
import {
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
conversationQueue,
filterCounts,
isConversationFilter,
isConversationSort,
@@ -91,7 +92,7 @@ describe('conversation triage', () => {
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
.toBe('Manual by policy');
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'killed' })))
.toBe('Killed — seller');
.toBeNull();
expect(manualFlowLabel(chat({ username: 'e', automationStatus: 'active' }))).toBeNull();
});
});
@@ -101,44 +102,32 @@ describe('conversation triage', () => {
const replied = chat({ username: 'replied', isFromUs: true });
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
const done = chat({ username: 'done', triageStatus: 'done' });
const withImage = chat({ username: 'image', hasReceivedImages: true });
const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' });
const archived = chat({ username: 'archived', readOnly: true });
const seller = chat({ username: 'seller', sellerDetected: true });
it('excludes snoozed and resolved conversations from the default view', () => {
expect(matchesFilter(open, 'all')).toBe(true);
expect(matchesFilter(replied, 'all')).toBe(true);
expect(matchesFilter(snoozed, 'all')).toBe(false);
expect(matchesFilter(done, 'all')).toBe(false);
});
it('keeps snoozed and resolved conversations reachable through their own buckets', () => {
it('assigns every conversation to one workflow queue', () => {
expect(conversationQueue(open)).toBe('needs-reply');
expect(conversationQueue(replied)).toBe('awaiting-reply');
expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
expect(matchesFilter(done, 'done')).toBe(true);
expect(matchesFilter(seller, 'sellers')).toBe(true);
expect(matchesFilter(archived, 'archived')).toBe(true);
});
it('moves deleted accounts out of actionable buckets into their own bucket', () => {
expect(matchesFilter(deleted, 'all')).toBe(false);
it('moves deleted and archived accounts out of actionable queues', () => {
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
expect(matchesFilter(deleted, 'account-deleted')).toBe(true);
expect(matchesFilter(archived, 'needs-reply')).toBe(false);
expect(matchesFilter(archived, 'archived')).toBe(true);
expect(needsReply(deleted)).toBe(false);
});
it('scopes unread and image buckets to open conversations', () => {
expect(matchesFilter(open, 'unread')).toBe(true);
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false);
expect(matchesFilter(withImage, 'received-images')).toBe(true);
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false);
});
it('collects conversations the operator is driving into the manual bucket', () => {
const manual = chat({ username: 'manual', automationStatus: 'user-overridden' });
expect(matchesFilter(manual, 'manual')).toBe(true);
expect(matchesFilter(open, 'manual')).toBe(false);
// Resolved work leaves the manual queue the same way it leaves the others.
expect(matchesFilter(
chat({ username: 'z', automationStatus: 'user-overridden', triageStatus: 'done' }),
'manual',
)).toBe(false);
it('gives seller and archive outcomes precedence over status and direction', () => {
expect(conversationQueue(chat({ username: 's', sellerDetected: true, isFromUs: true }))).toBe('sellers');
expect(conversationQueue(chat({ username: 'a', readOnly: true, sellerDetected: true }))).toBe('archived');
expect(conversationQueue(chat({ username: 'd', recipientAccountStatus: 'deleted', sellerDetected: true })))
.toBe('account-deleted');
});
it('matches search against username, snippet and loaded message text', () => {
@@ -169,33 +158,31 @@ describe('conversation triage', () => {
it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => {
const result = partitionConversations([
chat({ username: 'recent-inbound', lastMessageTime: 300 }),
chat({ username: 'replied-old', lastMessageTime: 50, isFromUs: true }),
chat({ username: 'stale-inbound', lastMessageTime: 100 }),
], filters({ sort: 'oldest-unanswered' }));
// Unanswered first, oldest of those leading; everything else newest-first.
expect(result.visible.map((item) => item.username))
.toEqual(['stale-inbound', 'recent-inbound', 'replied-old']);
.toEqual(['stale-inbound', 'recent-inbound']);
});
it('reports how many rows the seller filter removed', () => {
it('keeps sellers in their dedicated queue', () => {
const result = partitionConversations([
chat({ username: 'alice' }),
chat({ username: 'seller-one', sellerDetected: true }),
chat({ username: 'seller-two', sellerDetected: true }),
], filters());
], filters({ filter: 'sellers' }));
expect(result.visible.map((item) => item.username)).toEqual(['alice']);
expect(result.hiddenSellerCount).toBe(2);
expect(result.visible.map((item) => item.username)).toEqual(['seller-one', 'seller-two']);
});
it('includes sellers without counting them as hidden once revealed', () => {
it('applies unread, manual and image filters as secondary modifiers', () => {
const result = partitionConversations([
chat({ username: 'seller', sellerDetected: true }),
], filters({ showSellers: true }));
chat({ username: 'match', hasUnreadInbound: true, automationStatus: 'user-overridden', hasReceivedImages: true }),
chat({ username: 'read', hasUnreadInbound: false, automationStatus: 'user-overridden', hasReceivedImages: true }),
chat({ username: 'automatic', hasUnreadInbound: true, automationStatus: 'active', hasReceivedImages: true }),
], filters({ unreadOnly: true, manualOnly: true, imagesOnly: true }));
expect(result.visible).toHaveLength(1);
expect(result.hiddenSellerCount).toBe(0);
expect(result.visible.map((item) => item.username)).toEqual(['match']);
});
it('narrows to a single owning account', () => {
@@ -219,25 +206,25 @@ describe('conversation triage', () => {
});
describe('filterCounts', () => {
it('counts each bucket independently', () => {
it('counts mutually exclusive workflow queues', () => {
const counts = filterCounts([
chat({ username: 'unread-inbound', hasUnreadInbound: true }),
chat({ username: 'read-inbound', hasUnreadInbound: false }),
chat({ username: 'replied', isFromUs: true }),
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
chat({ username: 'done', triageStatus: 'done' }),
chat({ username: 'manual', isFromUs: true, automationStatus: 'user-overridden' }),
chat({ username: 'seller', sellerDetected: true }),
chat({ username: 'deleted', recipientAccountStatus: 'deleted' }),
chat({ username: 'archived', readOnly: true }),
], filters());
expect(counts.all).toBe(4);
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(2);
expect(counts['awaiting-reply']).toBe(2);
expect(counts.manual).toBe(1);
expect(counts['awaiting-reply']).toBe(1);
expect(counts.sellers).toBe(1);
expect(counts.snoozed).toBe(1);
expect(counts.done).toBe(1);
expect(counts['account-deleted']).toBe(1);
expect(counts.archived).toBe(1);
});
it('respects the search term so a pill never promises rows the list will not show', () => {
@@ -246,17 +233,16 @@ describe('conversation triage', () => {
chat({ username: 'bob', hasUnreadInbound: true }),
], filters({ search: 'alice' }));
expect(counts.unread).toBe(1);
expect(counts.all).toBe(1);
expect(counts['needs-reply']).toBe(1);
});
it('excludes hidden sellers from the counts', () => {
it('applies secondary filters to every queue count', () => {
const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }),
], filters());
chat({ username: 'read', hasUnreadInbound: false }),
], filters({ unreadOnly: true }));
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(1);
});
});
@@ -440,17 +440,21 @@ describe('application-level read state', () => {
expect(await screen.findByRole('button', { name: /u\/carol/ })).toBeInTheDocument();
});
it('explains rows hidden by the seller filter instead of silently dropping them', async () => {
it('keeps seller rows reachable through their dedicated queue', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'seller', sellerDetected: true }),
])),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('1 hidden by the seller filter.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sellers, 1' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Sellers, 1' }));
expect(screen.getByRole('button', { name: /u\/seller/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
});
it('flags an unanswered conversation that has gone stale', async () => {
+13 -10
View File
@@ -148,10 +148,12 @@ describe('ConversationsPage', () => {
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob');
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Waiting/ }));
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
await user.click(screen.getByRole('button', { name: /^Unread/ }));
await user.click(screen.getByRole('button', { name: /^Needs attention/ }));
await user.click(screen.getByRole('checkbox', { name: 'Unread only' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -185,7 +187,7 @@ describe('ConversationsPage', () => {
expect(previewTrigger).toHaveFocus();
});
it('hides detected sellers by default and reveals marked conversations on request', async () => {
it('keeps detected sellers in their dedicated queue', async () => {
const seller = chatFixture({ username: 'seller', roomId: 'room-seller', sellerDetected: true });
server.use(http.get('/api/conversations', () => HttpResponse.json([alice, seller])));
const user = userEvent.setup();
@@ -194,7 +196,7 @@ describe('ConversationsPage', () => {
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/seller/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('checkbox', { name: 'Show sellers' }));
await user.click(screen.getByRole('button', { name: /^Sellers/ }));
expect(screen.getByRole('button', { name: /u\/seller.*Seller/ })).toBeInTheDocument();
expect(screen.getByText('Seller')).toBeInTheDocument();
});
@@ -261,7 +263,7 @@ describe('ConversationsPage', () => {
const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Image received')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Images/ }));
await user.click(screen.getByRole('checkbox', { name: 'Has images' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -288,13 +290,14 @@ describe('ConversationsPage', () => {
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /^Waiting/ }));
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute(
'title',
'Manual — you replied — A manual reply was sent in this conversation.',
);
await user.click(screen.getByRole('button', { name: 'Manual, 1' }));
await user.click(screen.getByRole('checkbox', { name: 'Manual only' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
});
@@ -501,12 +504,12 @@ describe('ConversationsPage', () => {
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
// alice is inbound and unread; bob is a conversation we replied to last.
expect(screen.getByRole('button', { name: 'Unread, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Needs reply, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Waiting on them, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All, 2' })).toHaveAttribute('aria-pressed', 'true');
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, Needs reply, .+ago/);
expect(screen.getByRole('button', { name: 'Needs attention, 1' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Waiting, 1' })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'Unread only' })).not.toBeChecked();
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Needs attention, Unread, .+ago/);
expect(aliceButton.closest('li')).toHaveClass('is-unread');
await userEvent.setup().click(screen.getByRole('button', { name: 'Waiting, 1' }));
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
});
+7 -5
View File
@@ -33,20 +33,22 @@ describe('route-friendly filters', () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup();
renderRoute(
'/conversations?q=alice&filter=received-images&showSellers=true&sort=oldest-unanswered',
'/conversations?q=alice&filter=awaiting-reply&unread=true&manual=true&images=true&sort=oldest-unanswered',
<ConversationRoute />,
);
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
expect(screen.getByRole('button', { name: /^Images/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: /^Waiting/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('combobox', { name: 'Sort' })).toHaveValue('oldest-unanswered');
expect(screen.getByRole('checkbox', { name: 'Show sellers' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Unread only' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Manual only' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Has images' })).toBeChecked();
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
await user.click(screen.getByRole('button', { name: /^Needs reply/ }));
expect(screen.getByLabelText('Current URL')).toHaveTextContent('filter=needs-reply');
await user.click(screen.getByRole('button', { name: /^Needs attention/ }));
expect(screen.getByLabelText('Current URL')).not.toHaveTextContent('filter=');
});
it('hydrates and writes media filters', async () => {
+19 -3
View File
@@ -15,6 +15,7 @@ const OPTIONS: SellerSweepOptions = {
killConfidence: 0.8,
flagConfidence: 0.6,
killOnSellerKeyword: true,
offPlatformKill: true,
};
function conversation(overrides: Partial<SellerSweepConversation> = {}): SellerSweepConversation {
@@ -68,7 +69,7 @@ describe('sweepConversationsForSellers', () => {
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
expect(summary.deterministic).toBe(2);
expect(summary.aiRequests).toBe(0);
expect(summary.flagged.map((flag) => flag.source)).toEqual(['keyword', 'domain']);
expect(summary.flagged.map((flag) => flag.source)).toEqual(['funnel', 'funnel']);
expect(summary.flagged.every((flag) => flag.killed)).toBe(true);
expect(states.saved.get('room-keyword')?.sellerDetected).toBe(true);
expect(states.saved.get('room-keyword')?.automation.status).toBe('killed');
@@ -87,13 +88,28 @@ describe('sweepConversationsForSellers', () => {
expect(summary.flagged[0].source).toBe('funnel');
});
it('treats contact-platform and payment pushes as deterministic seller outcomes', async () => {
const states = stateStore();
const summary = await sweepConversationsForSellers([
conversation({ conversationId: 'telegram', messages: [{ sender: 'alice', text: 'add me on telegram', isSelf: false }] }),
conversation({ conversationId: 'snap', messages: [{ sender: 'alice', text: 'my snapchat is seller123', isSelf: false }] }),
conversation({ conversationId: 'cash', messages: [{ sender: 'alice', text: 'cash app is $seller123', isSelf: false }] }),
], OPTIONS, { states });
expect(summary.flagged.map((flag) => flag.source)).toEqual([
'off-platform', 'off-platform', 'off-platform',
]);
expect(summary.flagged.every((flag) => flag.killed)).toBe(true);
});
it('hands the deterministic kill decision to the configured policy', async () => {
const states = stateStore();
const triage = { setStatus: vi.fn() };
const summary = await sweepConversationsForSellers(
[conversation({ messages: [{ sender: 'alice', text: 'see my onlyfans', isSelf: false }] })],
{ ...OPTIONS, killOnSellerKeyword: false },
[conversation({ messages: [{ sender: 'alice', text: 'you could spoil me', isSelf: false }] })],
{ ...OPTIONS, sellerKeywords: ['spoil me'], killOnSellerKeyword: false },
{ states, triage },
);
+1 -1
View File
@@ -599,7 +599,7 @@ describe('conversations route', () => {
expect(body.flagged).toEqual([expect.objectContaining({
// Flags carry the owned id so the dashboard can match its list rows.
conversationId: `${encodeURIComponent('@t2_one:reddit.com')}~room-alice`,
username: 'alice', source: 'keyword', killed: true,
username: 'alice', source: 'funnel', killed: true,
})]);
// The keyword hit never reaches the model; only the clean row is screened.
expect(ai.screenConversationsForSellers.mock.calls[0][0]).toEqual([