PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.
Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.
Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.
New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.
Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
244 lines
8.8 KiB
TypeScript
244 lines
8.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({
|
|
processInboundMessage: vi.fn(),
|
|
finalizeBurst: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
vi.mock('@/lib/processing-history/append', () => ({
|
|
appendProcessingHistory: vi.fn().mockResolvedValue('event-1'),
|
|
}))
|
|
|
|
import {
|
|
processInboundMessage,
|
|
finalizeBurst,
|
|
} from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
|
|
import { runSweep } from '@/extensions/general/whatsapp-inbox/lib/sweep'
|
|
import {
|
|
COMPANY_CHOICE_EXPIRED,
|
|
STAGED_AWAITING_COMPANY,
|
|
} from '@/extensions/general/whatsapp-inbox/lib/conversation'
|
|
|
|
const processMock = vi.mocked(processInboundMessage)
|
|
const finalizeMock = vi.mocked(finalizeBurst)
|
|
|
|
const HOURS = 60 * 60 * 1000
|
|
|
|
function expiredConversation(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'conv-1',
|
|
phone_link_id: 'link-1',
|
|
state: 'awaiting_representation',
|
|
context: {
|
|
pending_question: {
|
|
type: 'representation',
|
|
inbox_item_id: 'item-9',
|
|
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
|
|
},
|
|
recent_questions: [
|
|
{
|
|
type: 'representation',
|
|
inbox_item_id: 'item-9',
|
|
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
|
|
status: 'open',
|
|
},
|
|
],
|
|
},
|
|
company_id: null,
|
|
service_window_expires_at: new Date(Date.now() - 25 * HOURS).toISOString(),
|
|
debounce_until: null,
|
|
pending_ack: false,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('runSweep', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
processMock.mockResolvedValue({ kind: 'media_processed', conversationId: 'conv-1' })
|
|
finalizeMock.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('re-claims stuck received rows, errors out max-attempts rows, finalizes touched bursts', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: [
|
|
{ id: 'm-fresh', attempts: 1, conversation_id: 'conv-1' },
|
|
{ id: 'm-dead', attempts: 3, conversation_id: 'conv-1' },
|
|
],
|
|
}) // stuck received
|
|
enqueue({ data: null }) // m-dead -> error update
|
|
enqueue({ data: [] }) // stuck processing
|
|
enqueue({ data: [] }) // stale pending_ack
|
|
enqueue({ data: [] }) // unacked re-arm
|
|
enqueue({ data: [] }) // TTL scan
|
|
enqueue({ data: [] }) // pin scan
|
|
|
|
const summary = await runSweep(supabase as unknown as SupabaseClient)
|
|
|
|
expect(processMock).toHaveBeenCalledTimes(1)
|
|
expect(processMock).toHaveBeenCalledWith(supabase, 'm-fresh')
|
|
expect(finalizeMock).toHaveBeenCalledWith(supabase, 'conv-1')
|
|
expect(summary.reclaimedReceived).toBe(1)
|
|
expect(summary.erroredMaxAttempts).toBe(1)
|
|
expect(summary.finalizedAcks).toBe(1)
|
|
|
|
const errorPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
|
|
expect(errorPatch.processing_status).toBe('error')
|
|
expect(errorPatch.error_message).toBe('Max attempts exceeded')
|
|
})
|
|
|
|
it('resets stuck processing rows back to received before re-running them', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [] }) // stuck received
|
|
enqueue({ data: [{ id: 'm-stuck', attempts: 1, conversation_id: 'conv-2' }] })
|
|
enqueue({ data: { id: 'm-stuck' } }) // guarded reset won
|
|
enqueue({ data: [] }) // stale pending_ack
|
|
enqueue({ data: [] }) // unacked re-arm
|
|
enqueue({ data: [] }) // TTL scan
|
|
enqueue({ data: [] }) // pin scan
|
|
|
|
const summary = await runSweep(supabase as unknown as SupabaseClient)
|
|
|
|
const resetPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
|
|
expect(resetPatch.processing_status).toBe('received')
|
|
expect(processMock).toHaveBeenCalledWith(supabase, 'm-stuck')
|
|
expect(summary.reclaimedProcessing).toBe(1)
|
|
})
|
|
|
|
it('claims stale pending_ack conversations through finalizeBurst', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [] }) // stuck received
|
|
enqueue({ data: [] }) // stuck processing
|
|
enqueue({ data: [{ id: 'conv-7' }] }) // stale pending_ack
|
|
enqueue({ data: [] }) // unacked re-arm
|
|
enqueue({ data: [] }) // TTL scan
|
|
enqueue({ data: [] }) // pin scan
|
|
|
|
await runSweep(supabase as unknown as SupabaseClient)
|
|
|
|
expect(finalizeMock).toHaveBeenCalledWith(supabase, 'conv-7')
|
|
})
|
|
|
|
it('expires a 48h-old question: item moved_to_app, conversation back to idle, NOTHING sent', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [] }) // stuck received
|
|
enqueue({ data: [] }) // stuck processing
|
|
enqueue({ data: [] }) // stale pending_ack
|
|
enqueue({ data: [] }) // unacked re-arm
|
|
enqueue({ data: [expiredConversation()] }) // TTL scan
|
|
enqueue({
|
|
data: {
|
|
channel_context: {
|
|
channel: 'whatsapp',
|
|
pending_question: {
|
|
type: 'representation',
|
|
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
|
|
status: 'open',
|
|
},
|
|
},
|
|
},
|
|
}) // item context load
|
|
enqueue({ data: null }) // item context update
|
|
enqueue({ data: { company_id: 'company-1', correlation_id: null } }) // history lookup
|
|
enqueue({ data: null }) // conversation -> idle
|
|
enqueue({ data: [] }) // pin scan
|
|
|
|
const summary = await runSweep(supabase as unknown as SupabaseClient)
|
|
|
|
expect(summary.expiredQuestions).toBe(1)
|
|
const itemPatch = findCalls('invoice_inbox_items', 'update')[0][0] as {
|
|
channel_context: { pending_question: { status: string } }
|
|
}
|
|
expect(itemPatch.channel_context.pending_question.status).toBe('moved_to_app')
|
|
|
|
const conversationPatch = findCalls('whatsapp_conversations', 'update')[0][0] as {
|
|
state: string
|
|
context: { pending_question?: unknown; recent_questions: { status: string }[] }
|
|
}
|
|
expect(conversationPatch.state).toBe('idle')
|
|
expect(conversationPatch.context.pending_question).toBeUndefined()
|
|
expect(conversationPatch.context.recent_questions[0].status).toBe('moved_to_app')
|
|
// Expiry is a silent hand-off: the service window is long gone.
|
|
expect(finalizeMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('expires an abandoned company question: parked rows get the expired marker', async () => {
|
|
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({
|
|
data: [
|
|
expiredConversation({
|
|
state: 'awaiting_company',
|
|
context: {
|
|
company_options: [{ id: 'company-1', name: 'A AB' }],
|
|
pending_question: {
|
|
type: 'company',
|
|
inbox_item_id: null,
|
|
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
})
|
|
enqueue({ data: null }) // staged rows marker update
|
|
enqueue({ data: null }) // conversation -> idle
|
|
enqueue({ data: [] }) // pin scan
|
|
|
|
await runSweep(supabase as unknown as SupabaseClient)
|
|
|
|
const markerPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
|
|
expect(markerPatch.error_message).toBe(COMPANY_CHOICE_EXPIRED)
|
|
expect(
|
|
calls.some(
|
|
(c) =>
|
|
c.table === 'whatsapp_messages' &&
|
|
c.method === 'eq' &&
|
|
c.args[0] === 'error_message' &&
|
|
c.args[1] === STAGED_AWAITING_COMPANY,
|
|
),
|
|
).toBe(true)
|
|
|
|
const conversationPatch = findCalls('whatsapp_conversations', 'update')[0][0] as {
|
|
state: string
|
|
context: { company_options?: unknown }
|
|
}
|
|
expect(conversationPatch.state).toBe('idle')
|
|
expect(conversationPatch.context.company_options).toBeUndefined()
|
|
})
|
|
|
|
it('clears expired 8h company pins', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] }) // TTL scan
|
|
enqueue({
|
|
data: [
|
|
expiredConversation({
|
|
state: 'idle',
|
|
company_id: 'company-1',
|
|
context: { pin_expires_at: new Date(Date.now() - 1000).toISOString(), pin_source: 'button' },
|
|
}),
|
|
],
|
|
}) // pin scan
|
|
|
|
const summary = await runSweep(supabase as unknown as SupabaseClient)
|
|
|
|
expect(summary.clearedPins).toBe(1)
|
|
const patch = findCalls('whatsapp_conversations', 'update')[0][0] as {
|
|
company_id: string | null
|
|
context: { pin_expires_at?: string; pin_source?: string }
|
|
}
|
|
expect(patch.company_id).toBeNull()
|
|
expect(patch.context.pin_expires_at).toBeUndefined()
|
|
expect(patch.context.pin_source).toBeUndefined()
|
|
})
|
|
})
|