Make conversation queues mutually exclusive
This commit is contained in:
1 parent
3bb1dda6bb
commit
2447563c60
20 files changed
+386
-220
No files matched your search
@@ -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'));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user