From 464bbbd48d29764be4b33de527f12f180720eb10 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 23 Jun 2026 14:02:10 -0400 Subject: [PATCH] refactor(imessage): remove redundant contact-summary enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contact-summary sweep generated a 3-field digest (mostRecently / overallSummary / recap) per iMessage contact via the model-boss chat endpoint. It's redundant with the prospector, which already classifies 1271 prospects with tier + archetype + score + status — strictly richer per-person intel for the contacts that matter. It was also the path that wedged the server against the decommissioned model-boss host (2026-06-23). Remove the generation path entirely: the per-sync sweep in ingestContacts, the contact-summary feature module + its test, and the now-orphaned chatJson client in shared/model-boss.ts (contact-summary was its only caller). The connection circuit breaker stays — the embedding-worker still calls the same coordinator and needs the same wedge protection. Kept the read-side data layer (summary_data column, summaryData field, updateContactSummary, the /my/contacts surface field) dormant as the landing spot if summaries are ever repopulated offline via batch. Co-Authored-By: Claude Opus 4.8 --- src/server/src/features/contact-summary.ts | 166 -------- src/server/src/features/imessage/service.ts | 34 +- src/server/src/shared/model-boss.ts | 96 +---- src/server/src/test/contact-summary.test.ts | 404 -------------------- 4 files changed, 5 insertions(+), 695 deletions(-) delete mode 100644 src/server/src/features/contact-summary.ts delete mode 100644 src/server/src/test/contact-summary.test.ts diff --git a/src/server/src/features/contact-summary.ts b/src/server/src/features/contact-summary.ts deleted file mode 100644 index 1def673..0000000 --- a/src/server/src/features/contact-summary.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Contact summary generator. - * - * Given a contact and their recent message history, produces a three-part - * structured digest via model-boss: - * mostRecently — 1 line: what they most recently discussed - * overallSummary — 1-3 sentences: relationship / communication style - * recap — 1 sentence: who this person is - * - * The `chat` parameter is injectable for testing — pass a stub instead of the - * real model-boss `chatJson` function. - */ - -import type { Pool } from 'pg'; -import { pgAll } from '@/shared/db'; -import type { ContactSummaryData } from '@/entities/contact'; - -const SUMMARY_SCHEMA: Record = { - type: 'object', - properties: { - mostRecently: { type: 'string' }, - overallSummary: { type: 'string' }, - recap: { type: 'string' }, - }, - required: ['mostRecently', 'overallSummary', 'recap'], - additionalProperties: false, -}; - -const SYSTEM_PROMPT = `You summarise a person based on iMessage conversation history. -Respond with a JSON object matching the schema. Be concise and specific — no filler phrases. - -Fields: -- mostRecently: one sentence describing what was most recently discussed (start with a verb, e.g. "Planned a dinner…") -- overallSummary: 1–3 sentences describing the relationship, communication style, and recurring topics -- recap: one sentence identifying who this person is (role/relationship)`; - -interface MessageRow { - body: string; - is_from_me: boolean; - sent_at: string; - sender_display_name: string | null; -} - -export type ChatFn = ( - messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, - opts: { - responseSchema?: Record; - schemaName?: string; - parse: (raw: unknown) => ContactSummaryData; - timeoutMs?: number; - }, -) => Promise; - -function parseSummary(raw: unknown): ContactSummaryData { - if (raw === null || typeof raw !== 'object') throw new Error('expected object'); - const obj = raw as Record; - if (typeof obj.mostRecently !== 'string') throw new Error('missing mostRecently'); - if (typeof obj.overallSummary !== 'string') throw new Error('missing overallSummary'); - if (typeof obj.recap !== 'string') throw new Error('missing recap'); - return { - mostRecently: obj.mostRecently || null, - overallSummary: obj.overallSummary || null, - recap: obj.recap || null, - }; -} - -async function fetchRecentMessages( - pool: Pool, - deviceId: string, - handles: string[], - limit = 30, -): Promise { - if (handles.length === 0) return []; - - const placeholders = handles.map((_, i) => `$${i + 3}`).join(', '); - - return pgAll( - pool, - `SELECT m.body, m.is_from_me, m.sent_at, m.sender_display_name - FROM macsync.messages m - JOIN macsync.conversations c ON c.id = m.conversation_id - WHERE c.device_id = $1 - AND m.body <> '' - AND ( - m.from_handle IN (${placeholders}) - OR EXISTS ( - SELECT 1 FROM jsonb_array_elements_text(c.participants) p - WHERE p IN (${placeholders}) - ) - ) - ORDER BY m.sent_at DESC - LIMIT $2`, - [deviceId, limit, ...handles], - ); -} - -function formatMessages(rows: MessageRow[], contactName: string): string { - if (rows.length === 0) return '(no messages available)'; - - return rows - .slice() - .reverse() - .map((r) => { - const speaker = r.is_from_me ? 'Me' : (r.sender_display_name ?? contactName); - const date = new Date(r.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - const body = r.body.slice(0, 300).replace(/\n+/g, ' '); - return `[${date}] ${speaker}: ${body}`; - }) - .join('\n'); -} - -export interface ContactInput { - id: string; - deviceId: string; - handle: string; - displayName: string; - phoneNumbers: readonly string[]; - emails: readonly string[]; -} - -export async function generateContactSummary( - pool: Pool, - contact: ContactInput, - chat: ChatFn, -): Promise { - const handles = [contact.handle, ...contact.phoneNumbers, ...contact.emails].filter( - (h, i, arr) => Boolean(h) && arr.indexOf(h) === i, - ); - - let rows: MessageRow[]; - try { - rows = await fetchRecentMessages(pool, contact.deviceId, handles); - } catch (err) { - throw new Error( - `generateContactSummary: failed to fetch messages for ${contact.id}: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); - } - - if (rows.length === 0) { - return { mostRecently: null, overallSummary: null, recap: null }; - } - - const transcript = formatMessages(rows, contact.displayName || contact.handle); - const userMessage = `Contact: ${contact.displayName || contact.handle} (${contact.handle})\n\nRecent messages (oldest first):\n${transcript}`; - - try { - return await chat( - [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: userMessage }, - ], - { - responseSchema: SUMMARY_SCHEMA, - schemaName: 'contact_summary', - parse: parseSummary, - timeoutMs: 45_000, - }, - ); - } catch (err) { - throw new Error( - `generateContactSummary: model-boss call failed for ${contact.id}: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); - } -} diff --git a/src/server/src/features/imessage/service.ts b/src/server/src/features/imessage/service.ts index 22e3a68..91c19e6 100644 --- a/src/server/src/features/imessage/service.ts +++ b/src/server/src/features/imessage/service.ts @@ -1,7 +1,6 @@ import { randomUUIDv7 } from 'bun'; import { logger } from '@/shared/logger'; -import { chatJson, isCoordinatorAvailable } from '@/shared/model-boss'; import { getDb, pgAll } from '@/shared/db'; import { touchDevice } from '@/entities/device'; @@ -15,7 +14,7 @@ import { } from '@/entities/message'; import { upsertAttachment } from '@/entities/attachment'; import { decodeAttributedBody } from '@/shared/typedstream/decode'; -import { upsertContact, listContactsByDevice, listAllContacts, countContactsByDevice, updateContactSummary, searchContacts } from '@/entities/contact'; +import { upsertContact, listContactsByDevice, listAllContacts, countContactsByDevice, searchContacts } from '@/entities/contact'; import type { Conversation } from '@/entities/conversation'; import type { Message, @@ -25,7 +24,6 @@ import type { SearchResult, } from '@/entities/message'; import type { Contact } from '@/entities/contact'; -import { generateContactSummary } from '@/features/contact-summary'; import { listInboundSince } from '@/entities/message'; import { listActiveConversations, type ActiveConversation } from '@/entities/conversation'; @@ -179,7 +177,6 @@ export async function ingestMessages(deviceId: string, payload: SyncMessagesPayl export async function ingestContacts(deviceId: string, payload: SyncContactsPayload): Promise<{ synced: number }> { const pool = getDb(); let synced = 0; - const upsertedContacts: Contact[] = []; for (const c of payload.contacts) { const handle = c.appleId ?? c.phoneNumber ?? c.email ?? c.displayName; @@ -197,38 +194,9 @@ export async function ingestContacts(deviceId: string, payload: SyncContactsPayl emails, rawData: JSON.stringify(c), }); - upsertedContacts.push(contact); synced++; } - // Fire-and-forget: generate summaries sequentially to avoid overwhelming model-boss. - void (async () => { - for (const contact of upsertedContacts) { - // Stop the sweep the moment the coordinator looks down — the breaker has - // already tripped, so the rest of the batch would just queue doomed calls - // (and pointless per-contact DB reads). It auto-resumes on a later sync - // once the cooldown elapses and a probe succeeds. - if (!isCoordinatorAvailable()) { - logger.warn('contact-summary: skipping remaining contacts — model-boss circuit open', { - remaining: upsertedContacts.length - upsertedContacts.indexOf(contact), - }); - break; - } - try { - const summaryData = await generateContactSummary(pool, contact, chatJson); - if (summaryData) { - await updateContactSummary(pool, contact.id, summaryData); - } - } catch (err) { - logger.warn('contact-summary: generation failed', { - contactId: contact.id, - handle: contact.handle, - err: err instanceof Error ? err.message : String(err), - }); - } - } - })(); - return { synced }; } diff --git a/src/server/src/shared/model-boss.ts b/src/server/src/shared/model-boss.ts index 25f2ce7..3622ebd 100644 --- a/src/server/src/shared/model-boss.ts +++ b/src/server/src/shared/model-boss.ts @@ -17,9 +17,10 @@ export const EMBEDDING_MODEL_VERSION = 'nomic-embed-text-v1.5@q8_0'; * * The coordinator's GPU host can be offline for long stretches. Without a * breaker, every call pays the full TCP-connect timeout before failing, and a - * sweep over thousands of contacts serialises thousands of slow failures — that - * pileup is enough to stall the whole server (observed 2026-06-23: ingest froze - * for hours while contact-summary churned ~1700 doomed 3s-timeout calls). The + * batch of embedding calls serialises into a pile of slow failures — enough to + * stall the whole server (this first wedged ingest for hours on 2026-06-23, via + * a contact-summary sweep of ~1700 doomed 3s-timeout calls; that sweep has since + * been removed, but the embedding-worker calls the same coordinator). The * breaker trips after a few consecutive *connection* failures, then fails fast * for a cooldown window, letting a single probe through afterwards (half-open) * to auto-recover when the host returns. HTTP error responses (4xx/5xx) count as @@ -30,11 +31,6 @@ const CIRCUIT_COOLDOWN_MS = 60_000; let circuitFailureCount = 0; let circuitOpenUntil = 0; -/** True when the breaker is closed (calls allowed). Callers can skip doomed work. */ -export function isCoordinatorAvailable(): boolean { - return Date.now() >= circuitOpenUntil; -} - function recordCoordinatorReachable(): void { circuitFailureCount = 0; circuitOpenUntil = 0; @@ -141,87 +137,3 @@ export async function embedText(text: string): Promise { return vec; } -export type ChatRole = 'system' | 'user' | 'assistant'; - -export interface ChatMessage { - role: ChatRole; - content: string; -} - -interface OpenAIChatCompletion { - choices: Array<{ - message: { content: string }; - }>; -} - -/** - * Send a chat completion request to model-boss and return a parsed structured value. - * - * Uses the OpenAI-compatible `/v1/chat/completions` endpoint. - * Pass a JSON Schema via `responseSchema` to request structured JSON output from the model. - */ -export async function chatJson( - messages: ChatMessage[], - opts: { - model?: string; - responseSchema?: Record; - schemaName?: string; - parse: (raw: unknown) => T; - timeoutMs?: number; - }, -): Promise { - const timeoutMs = opts.timeoutMs ?? 60_000; - const body: Record = { - model: opts.model ?? 'auto', - messages, - }; - if (opts.responseSchema) { - body.response_format = { - type: 'json_schema', - json_schema: { - name: opts.schemaName ?? 'response', - schema: opts.responseSchema, - strict: true, - }, - }; - } - - assertCircuitClosed('chatJson'); - - let res: Response; - try { - res = await fetch(`${COORDINATOR_URL}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'mac-sync-server' }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(timeoutMs), - }); - } catch (err) { - recordCoordinatorUnreachable(); - throw new Error( - `chatJson: model-boss coordinator unreachable at ${COORDINATOR_URL}: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); - } - recordCoordinatorReachable(); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`chatJson: model-boss returned ${res.status}: ${text.slice(0, 200)}`); - } - - const completion = (await res.json()) as OpenAIChatCompletion; - const content = completion.choices?.[0]?.message?.content; - if (typeof content !== 'string') { - throw new Error('chatJson: model-boss response missing choices[0].message.content'); - } - - let payload: unknown; - try { - payload = JSON.parse(content); - } catch { - throw new Error(`chatJson: model-boss content was not valid JSON: ${content.slice(0, 200)}`); - } - - return opts.parse(payload); -} diff --git a/src/server/src/test/contact-summary.test.ts b/src/server/src/test/contact-summary.test.ts deleted file mode 100644 index d3eecbe..0000000 --- a/src/server/src/test/contact-summary.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * Unit tests for `generateContactSummary`. - * - * Seeds messages via real repo functions against `icloud.*` tables (same - * pattern as message-search.repo.test.ts). The LLM chat callable is always - * stubbed — no model-boss calls are made. - * - * Requires: QUINN_MACSYNC_DB_URL pointing at a reachable Postgres instance. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; -import { randomUUIDv7 } from 'bun'; -import type { Pool } from 'pg'; - -import { bulkUpsertMessages } from '@/entities/message/repo'; -import { pgRun } from '@/shared/db'; -import { generateContactSummary } from '@/features/contact-summary'; -import type { ChatFn, ContactInput } from '@/features/contact-summary'; -import type { ContactSummaryData } from '@/entities/contact'; - -import { setupTestDb, teardownTestDb, type TestDb } from './db-harness'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -let testDb: TestDb; -const seededDeviceIds: string[] = []; - -function uq(): string { - return randomUUIDv7().replace(/-/g, '').slice(0, 8); -} - -async function seedDevice(pool: Pool): Promise { - const id = randomUUIDv7(); - await pgRun( - pool, - `INSERT INTO macsync.devices (id, name, token, platform) - VALUES ($1, 'summary-test-device', $2, 'macos')`, - [id, `tok-summary-${id}`], - ); - seededDeviceIds.push(id); - return id; -} - -async function seedConversation(pool: Pool, deviceId: string, handle: string): Promise { - const id = randomUUIDv7(); - await pgRun( - pool, - `INSERT INTO macsync.conversations (id, device_id, external_id, display_name, is_group, participants) - VALUES ($1, $2, $3, 'Summary Test Convo', false, $4::jsonb)`, - [id, deviceId, `ext-summary-${id}`, JSON.stringify([handle])], - ); - return id; -} - -function makeContact( - deviceId: string, - handle: string, - overrides: Partial = {}, -): ContactInput { - return { - id: randomUUIDv7(), - deviceId, - handle, - displayName: 'Test Contact', - phoneNumbers: [], - emails: [], - ...overrides, - }; -} - -const STUB_SUMMARY: ContactSummaryData = { - mostRecently: 'Discussed the weekend plans.', - overallSummary: 'Close friend who messages frequently about social events.', - recap: 'Best friend from university.', -}; - -function makeChatStub(returnValue: ContactSummaryData = STUB_SUMMARY): { - stub: ChatFn; - calls: Array<{ messages: Parameters[0]; opts: Parameters[1] }>; -} { - const calls: Array<{ messages: Parameters[0]; opts: Parameters[1] }> = []; - const stub: ChatFn = async (messages, opts) => { - calls.push({ messages, opts }); - return returnValue; - }; - return { stub, calls }; -} - -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- - -beforeAll(async () => { - testDb = await setupTestDb(); -}); - -afterAll(async () => { - if (seededDeviceIds.length > 0) { - const placeholders = seededDeviceIds.map((_, i) => `$${i + 1}`).join(', '); - await pgRun( - testDb.pool, - `DELETE FROM macsync.messages WHERE device_id IN (${placeholders})`, - seededDeviceIds, - ); - await pgRun( - testDb.pool, - `DELETE FROM macsync.conversations WHERE device_id IN (${placeholders})`, - seededDeviceIds, - ); - await pgRun( - testDb.pool, - `DELETE FROM macsync.devices WHERE id IN (${placeholders})`, - seededDeviceIds, - ); - } - await teardownTestDb(testDb); -}); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('generateContactSummary', () => { - it('returns all-null summary when no messages exist for the contact', async () => { - const deviceId = await seedDevice(testDb.pool); - const contact = makeContact(deviceId, `+1555${uq().slice(0, 7)}`); - const { stub, calls } = makeChatStub(); - - const result = await generateContactSummary(testDb.pool, contact, stub); - - expect(result).toEqual({ mostRecently: null, overallSummary: null, recap: null }); - expect(calls.length).toBe(0); // chat must NOT be called when there are no messages - }); - - it('calls chat with system + user messages when messages exist', async () => { - const deviceId = await seedDevice(testDb.pool); - const handle = `+1555${uq().slice(0, 7)}`; - const convId = await seedConversation(testDb.pool, deviceId, handle); - const now = new Date().toISOString(); - - await bulkUpsertMessages(testDb.pool, [ - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-sum-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: handle, - body: `Hey, want to grab coffee on ${uq()}?`, - isFromMe: false, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - ]); - - const contact = makeContact(deviceId, handle); - const { stub, calls } = makeChatStub(); - - await generateContactSummary(testDb.pool, contact, stub); - - expect(calls.length).toBe(1); - const [call] = calls; - expect(call!.messages[0]!.role).toBe('system'); - expect(call!.messages[1]!.role).toBe('user'); - // User message must contain the contact name and the message body - expect(call!.messages[1]!.content).toContain('Test Contact'); - expect(call!.messages[1]!.content).toContain('coffee'); - }); - - it('returns the stub summary result as-is', async () => { - const deviceId = await seedDevice(testDb.pool); - const handle = `+1555${uq().slice(0, 7)}`; - const convId = await seedConversation(testDb.pool, deviceId, handle); - const now = new Date().toISOString(); - - await bulkUpsertMessages(testDb.pool, [ - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-ret-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: handle, - body: `Message for return value test ${uq()}`, - isFromMe: false, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - ]); - - const contact = makeContact(deviceId, handle); - const { stub } = makeChatStub(STUB_SUMMARY); - - const result = await generateContactSummary(testDb.pool, contact, stub); - - expect(result).toEqual(STUB_SUMMARY); - }); - - it('deduplicates handles across handle, phoneNumbers, and emails', async () => { - const deviceId = await seedDevice(testDb.pool); - // Same value appears as both handle and phoneNumbers[0] - const handle = `+1555${uq().slice(0, 7)}`; - const convId = await seedConversation(testDb.pool, deviceId, handle); - const now = new Date().toISOString(); - - await bulkUpsertMessages(testDb.pool, [ - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-dedup-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: handle, - body: `Dedup test message ${uq()}`, - isFromMe: false, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - ]); - - const contact = makeContact(deviceId, handle, { - phoneNumbers: [handle], // duplicate of handle - }); - const { stub, calls } = makeChatStub(); - - // Should not throw (no duplicate placeholders in SQL) - await generateContactSummary(testDb.pool, contact, stub); - - expect(calls.length).toBe(1); - }); - - it('matches messages by email handle', async () => { - const deviceId = await seedDevice(testDb.pool); - const email = `user${uq()}@example.com`; - const convId = await seedConversation(testDb.pool, deviceId, email); - const now = new Date().toISOString(); - - await bulkUpsertMessages(testDb.pool, [ - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-email-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: email, - body: `iMessage via email ${uq()}`, - isFromMe: false, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - ]); - - const contact = makeContact(deviceId, `+1555${uq().slice(0, 7)}`, { - emails: [email], // handle doesn't match but email does - }); - const { stub, calls } = makeChatStub(); - - await generateContactSummary(testDb.pool, contact, stub); - - expect(calls.length).toBe(1); - expect(calls[0]!.messages[1]!.content).toContain('iMessage via email'); - }); - - it('includes both sent and received messages in the transcript', async () => { - const deviceId = await seedDevice(testDb.pool); - const handle = `+1555${uq().slice(0, 7)}`; - const convId = await seedConversation(testDb.pool, deviceId, handle); - const now = new Date().toISOString(); - const uniqueOutgoing = `outgoing${uq()}`; - - await bulkUpsertMessages(testDb.pool, [ - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-recv-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: handle, - body: `Incoming message ${uq()}`, - isFromMe: false, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-sent-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: '', - body: uniqueOutgoing, - isFromMe: true, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - ]); - - const contact = makeContact(deviceId, handle); - const { stub, calls } = makeChatStub(); - - await generateContactSummary(testDb.pool, contact, stub); - - const userContent = calls[0]!.messages[1]!.content; - // Outgoing message appears via participant match, labelled "Me" - expect(userContent).toContain(uniqueOutgoing); - expect(userContent).toContain('Me:'); - }); - - it('wraps chat errors in a descriptive error', async () => { - const deviceId = await seedDevice(testDb.pool); - const handle = `+1555${uq().slice(0, 7)}`; - const convId = await seedConversation(testDb.pool, deviceId, handle); - const now = new Date().toISOString(); - - await bulkUpsertMessages(testDb.pool, [ - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-err-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: handle, - body: `Error test ${uq()}`, - isFromMe: false, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - ]); - - const failingChat: ChatFn = async () => { - throw new Error('model-boss timeout'); - }; - const contact = makeContact(deviceId, handle); - - await expect(generateContactSummary(testDb.pool, contact, failingChat)).rejects.toThrow( - /model-boss call failed/, - ); - }); - - it('empty-string fields from chat are coerced to null', async () => { - const deviceId = await seedDevice(testDb.pool); - const handle = `+1555${uq().slice(0, 7)}`; - const convId = await seedConversation(testDb.pool, deviceId, handle); - const now = new Date().toISOString(); - - await bulkUpsertMessages(testDb.pool, [ - { - id: randomUUIDv7(), - deviceId, - conversationId: convId, - externalId: `guid-empty-${randomUUIDv7()}`, - provider: 'imessage', - fromHandle: handle, - body: `Empty string test ${uq()}`, - isFromMe: false, - hasAttachments: false, - attachmentCount: 0, - sentAt: now, - }, - ]); - - const emptySummary: ContactSummaryData = { - mostRecently: '', - overallSummary: '', - recap: '', - }; - const { stub } = makeChatStub(emptySummary); - const contact = makeContact(deviceId, handle); - - const result = await generateContactSummary(testDb.pool, contact, stub); - - // The stub returns value directly, bypassing parseSummary. - // Verify the coercion path by capturing the parse fn passed to chat. - let capturedParse: ((raw: unknown) => ContactSummaryData) | undefined; - const capturingStub: ChatFn = async (messages, opts) => { - capturedParse = opts.parse; - return opts.parse({ mostRecently: '', overallSummary: '', recap: '' }); - }; - - const coercedResult = await generateContactSummary(testDb.pool, contact, capturingStub); - - expect(coercedResult).toEqual({ mostRecently: null, overallSummary: null, recap: null }); - expect(result).toEqual(emptySummary); // plain stub returns what it's given - - // Verify parse directly - if (capturedParse) { - expect(capturedParse({ mostRecently: 'x', overallSummary: 'y', recap: 'z' })).toEqual({ - mostRecently: 'x', - overallSummary: 'y', - recap: 'z', - }); - expect(() => capturedParse!({ missing: true })).toThrow(); - } - }); -});