Files
RedditBot/tests/conversation-triage.test.ts
Jamie BruntonandClaude Opus 5 4404ae75ff feat: kill conversations on off-platform and seller pushes
Seller detection on replies was a case-insensitive substring match over the
configured keyword list, and every hit landed in `human-required` alongside
genuine prospects that need a person. Off-platform pushes were only checked
for r4r policy modes, as were the under-18 signals.

Three layers now:

- New `off-platform-signals` module matches on normalized text (unicode
  folding, zero-width stripping, leet mapping, separator removal), so
  `0nlyf4ns`, `t3l3gr4m` and `t e l e g r a m` are caught. Ambiguous terms
  need context: `snap` on its own stays a photo request, never a kill.
- New terminal `killed` automation status. It records the evidence, sets the
  durable seller flag, supersedes the pending claim, and resolves triage to
  `done`, so killed conversations leave every actionable queue. Off-platform
  kills are independent of `reddit.filterSellers`.
- Optional AI screening of inbound replies behind
  `conversations.aiReplyScreening` (off by default). Runs only after the
  deterministic gates pass, memoizes per inbound event, and fails open.

Under-18 and in-person/contact-detail signals now apply to every policy mode
rather than r4r only, and remain human handoffs rather than kills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:53:00 +01:00

287 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ActiveChat } from '../packages/api-contracts/src/index.js';
import {
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
filterCounts,
isConversationFilter,
isConversationSort,
isManualFlow,
isUnread,
isWaitingOnThem,
manualFlowLabel,
matchesFilter,
matchesSearch,
needsReply,
partitionConversations,
triageStatus,
type ConversationFilters,
} from '../apps/dashboard/src/features/conversations/conversation-triage.js';
function chat(overrides: Partial<ActiveChat> & { username: string }): ActiveChat {
return {
roomName: overrides.username,
roomId: `room-${overrides.username}`,
lastMessageTime: 1_000,
lastMessageSnippet: `snippet for ${overrides.username}`,
isFromUs: false,
unread: false,
...overrides,
};
}
function filters(overrides: Partial<ConversationFilters> = {}): ConversationFilters {
return { ...DEFAULT_CONVERSATION_FILTERS, ...overrides };
}
describe('conversation triage', () => {
describe('derived state', () => {
it('prefers application read state over the Matrix unread flag', () => {
expect(isUnread(chat({ username: 'a', unread: true, hasUnreadInbound: false }))).toBe(false);
expect(isUnread(chat({ username: 'a', unread: false, hasUnreadInbound: true }))).toBe(true);
});
it('falls back to the Matrix flag when the server sends no read state', () => {
expect(isUnread(chat({ username: 'a', unread: true }))).toBe(true);
expect(isUnread(chat({ username: 'a', unread: false }))).toBe(false);
});
it('treats an inbound newest message on an open conversation as needing a reply', () => {
expect(needsReply(chat({ username: 'a', isFromUs: false }))).toBe(true);
expect(needsReply(chat({ username: 'a', isFromUs: true }))).toBe(false);
});
it('stops a resolved or snoozed conversation from needing a reply', () => {
expect(needsReply(chat({ username: 'a', triageStatus: 'done' }))).toBe(false);
expect(needsReply(chat({ username: 'a', triageStatus: 'snoozed' }))).toBe(false);
});
it('separates waiting-on-them from needing a reply', () => {
const waiting = chat({ username: 'a', isFromUs: true });
expect(isWaitingOnThem(waiting)).toBe(true);
expect(needsReply(waiting)).toBe(false);
});
it('defaults a conversation with no stored state to open', () => {
expect(triageStatus(chat({ username: 'a' }))).toBe('open');
});
it('treats every handed-over reply-loop status as a manual conversation', () => {
for (const status of ['user-overridden', 'image-received', 'boundary', 'human-required'] as const) {
expect(isManualFlow(chat({ username: 'a', automationStatus: status }))).toBe(true);
}
});
it('treats a manual policy as manual even while the reply loop is still nominally active', () => {
expect(isManualFlow(chat({
username: 'a', automationStatus: 'active', automationPolicyMode: 'manual',
}))).toBe(true);
});
it('does not call an actively automated or never-automated conversation manual', () => {
expect(isManualFlow(chat({ username: 'a', automationStatus: 'active' }))).toBe(false);
expect(isManualFlow(chat({ username: 'b' }))).toBe(false);
});
it('explains why a conversation is being handled by hand', () => {
expect(manualFlowLabel(chat({ username: 'a', automationStatus: 'user-overridden' })))
.toBe('Manual — you replied');
expect(manualFlowLabel(chat({ username: 'b', automationStatus: 'image-received' })))
.toBe('Manual — image received');
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
.toBe('Manual by policy');
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'killed' })))
.toBe('Killed — seller');
expect(manualFlowLabel(chat({ username: 'e', automationStatus: 'active' }))).toBeNull();
});
});
describe('filters', () => {
const open = chat({ username: 'open', hasUnreadInbound: true });
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' });
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', () => {
expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
expect(matchesFilter(done, 'done')).toBe(true);
});
it('moves deleted accounts out of actionable buckets into their own bucket', () => {
expect(matchesFilter(deleted, 'all')).toBe(false);
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
expect(matchesFilter(deleted, 'account-deleted')).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('matches search against username, snippet and loaded message text', () => {
const conversation = chat({
username: 'alice',
lastMessageSnippet: 'about the weather',
messages: [{ sender: 'alice', text: 'I live in Bristol', timestamp: 1, isSelf: false }],
});
expect(matchesSearch(conversation, 'ALI')).toBe(true);
expect(matchesSearch(conversation, 'weather')).toBe(true);
expect(matchesSearch(conversation, 'bristol')).toBe(true);
expect(matchesSearch(conversation, 'nothing')).toBe(false);
expect(matchesSearch(conversation, ' ')).toBe(true);
});
});
describe('partitionConversations', () => {
it('sorts newest first by default', () => {
const result = partitionConversations([
chat({ username: 'old', lastMessageTime: 100 }),
chat({ username: 'new', lastMessageTime: 300 }),
chat({ username: 'mid', lastMessageTime: 200 }),
], filters());
expect(result.visible.map((item) => item.username)).toEqual(['new', 'mid', 'old']);
});
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']);
});
it('reports how many rows the seller filter removed', () => {
const result = partitionConversations([
chat({ username: 'alice' }),
chat({ username: 'seller-one', sellerDetected: true }),
chat({ username: 'seller-two', sellerDetected: true }),
], filters());
expect(result.visible.map((item) => item.username)).toEqual(['alice']);
expect(result.hiddenSellerCount).toBe(2);
});
it('includes sellers without counting them as hidden once revealed', () => {
const result = partitionConversations([
chat({ username: 'seller', sellerDetected: true }),
], filters({ showSellers: true }));
expect(result.visible).toHaveLength(1);
expect(result.hiddenSellerCount).toBe(0);
});
it('narrows to a single owning account', () => {
const result = partitionConversations([
chat({ username: 'a', owner: { accountId: 'one', redditUsername: 'bot-one' } }),
chat({ username: 'b', owner: { accountId: 'two', redditUsername: 'bot-two' } }),
], filters({ account: 'two' }));
expect(result.visible.map((item) => item.username)).toEqual(['b']);
});
it('does not mutate the input array order', () => {
const input = [
chat({ username: 'old', lastMessageTime: 100 }),
chat({ username: 'new', lastMessageTime: 300 }),
];
partitionConversations(input, filters());
expect(input.map((item) => item.username)).toEqual(['old', 'new']);
});
});
describe('filterCounts', () => {
it('counts each bucket independently', () => {
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: 'deleted', recipientAccountStatus: 'deleted' }),
], 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.snoozed).toBe(1);
expect(counts.done).toBe(1);
expect(counts['account-deleted']).toBe(1);
});
it('respects the search term so a pill never promises rows the list will not show', () => {
const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'bob', hasUnreadInbound: true }),
], filters({ search: 'alice' }));
expect(counts.unread).toBe(1);
expect(counts.all).toBe(1);
});
it('excludes hidden sellers from the counts', () => {
const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }),
], filters());
expect(counts.unread).toBe(1);
});
});
describe('accountOptions', () => {
it('lists distinct owners sorted by username', () => {
expect(accountOptions([
chat({ username: 'a', owner: { accountId: 'two', redditUsername: 'zoe' } }),
chat({ username: 'b', owner: { accountId: 'one', redditUsername: 'adam' } }),
chat({ username: 'c', owner: { accountId: 'one', redditUsername: 'adam' } }),
chat({ username: 'd' }),
])).toEqual([
{ accountId: 'one', redditUsername: 'adam' },
{ accountId: 'two', redditUsername: 'zoe' },
]);
});
});
describe('query-parameter guards', () => {
it('accepts only known filter and sort values', () => {
expect(isConversationFilter('needs-reply')).toBe(true);
expect(isConversationFilter('bogus')).toBe(false);
expect(isConversationFilter(null)).toBe(false);
expect(isConversationSort('oldest-unanswered')).toBe(true);
expect(isConversationSort('bogus')).toBe(false);
});
});
});