refactor(imessage): remove redundant contact-summary enrichment

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 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-23 14:02:10 -04:00
parent 1ebbd8e872
commit 464bbbd48d
4 changed files with 5 additions and 695 deletions

View file

@ -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<string, unknown> = {
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: 13 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<string, unknown>;
schemaName?: string;
parse: (raw: unknown) => ContactSummaryData;
timeoutMs?: number;
},
) => Promise<ContactSummaryData>;
function parseSummary(raw: unknown): ContactSummaryData {
if (raw === null || typeof raw !== 'object') throw new Error('expected object');
const obj = raw as Record<string, unknown>;
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<MessageRow[]> {
if (handles.length === 0) return [];
const placeholders = handles.map((_, i) => `$${i + 3}`).join(', ');
return pgAll<MessageRow>(
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<ContactSummaryData | null> {
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 },
);
}
}

View file

@ -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 };
}

View file

@ -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<number[]> {
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<T>(
messages: ChatMessage[],
opts: {
model?: string;
responseSchema?: Record<string, unknown>;
schemaName?: string;
parse: (raw: unknown) => T;
timeoutMs?: number;
},
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 60_000;
const body: Record<string, unknown> = {
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);
}

View file

@ -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<string> {
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<string> {
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> = {},
): 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<ChatFn>[0]; opts: Parameters<ChatFn>[1] }>;
} {
const calls: Array<{ messages: Parameters<ChatFn>[0]; opts: Parameters<ChatFn>[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();
}
});
});