Files
gbrain/test/extract-conversation-facts.test.ts
T
84fed4194a v0.41.11.0 feat: conversation retrieval upgrade — production-bar replacement for PR #1406 (#1446)
* v0.41.11.0 feat: conversation retrieval upgrade — production-bar replacement for PR #1406

Long chat threads stop swallowing your search results. The recall miss
class on long iMessage/Slack imports (60K+ msg history; a chunk that
reads only "Locker 93 code 9494" has no topical anchor because "cabin"
was established 50K messages earlier) gets fixed by walking
conversation/meeting/slack/email pages, splitting into time-windowed
segments (30-min gap or 30-msg cap), prepending a topical/temporal
header, and running through the existing extractFactsFromTurn() so
the resulting anchor-rich facts surface in gbrain search.

This is the production-bar replacement for PR #1406 (which closes
LAST per Codex T6d, AFTER this PR is green). The bug fix survives 1:1;
the wrapping closes 14 load-bearing issues the original PR deferred
or shipped silent bugs around. The wave went through CEO scope review,
3 rounds of spec review, 2 rounds of Codex outside voice grounding
the plan against actual code, and 2 passes of eng review.

Version-slot note: originally planned as v0.41.2.0; master shipped its
own v0.41.2.0 (lens packs) plus v0.41.3-6.0 between plan-time and
ship-time. Re-bumped to v0.41.11.0 (next free slot; v0.41.7-10 claimed
by other open PRs).

Key files (new):
- src/commands/extract-conversation-facts.ts — CLI command with
  --types, --max-cost-usd, --background, --override-disabled,
  --slug, --dry-run, --limit, --since, --force, --sleep,
  --segment-limit, --source-id. Strict per-source core; two-phase
  page enumeration (paginated listPages with 10×25MB cap = 250MB
  worst case); 25MB body cap; page-global row_num accumulator
  (Codex C1 unique-index collision fix); page-level TERMINAL audit
  row after all segments commit (Codex C7 durable extraction marker);
  optional opts.budgetTracker (Codex C5 — nested withBudgetTracker
  REPLACES, so caller-managed scope passes tracker through); reads
  compiled_truth + timeline (F1 — PR silently dropped timeline half);
  honors facts.extraction_enabled kill-switch with --override-disabled
  escape (F2); --types reads cycle config as single source of truth
  (Eng-v2 A2); fingerprint on sourceId only (Eng-v2 A3 — widening
  types doesn't invalidate completion); string-encoded op-checkpoint
  entries "sourceId|slug|endIso" for resume; segment caps tuned
  6500/30 (Eng-v2 T5) to stay under extract.ts MAX_TURN_TEXT_CHARS=8000.
- src/core/cycle/conversation-facts-backfill.ts — cycle phase wrapper
  (default OFF). Iterates listSources() directly; creates ONE
  brain-wide BudgetTracker per tick + wraps the loop in
  withBudgetTracker + passes tracker through opts.budgetTracker so
  core doesn't nest-replace. Two-layer cost AND walltime protection:
  per-source caps ($1, 20min) AND brain-wide caps ($5, 30min).
- test/extract-conversation-facts.test.ts — 27 unit cases (parse,
  segment, render, checkpoint encoding, fingerprint, terminal audit
  row, row_num accumulator, F2 kill-switch, --override-disabled).
- skills/migrations/v0.41.11.0.md — agent-facing migration guide.

Key files (modified):
- src/commands/jobs.ts — register extract-conversation-facts Minion
  handler. NOT in PROTECTED_JOB_NAMES; BudgetExhausted catch + persist
  + mark completed with result.budget_exhausted (NOT a failure).
- src/commands/doctor.ts — computeConversationFactsBacklogCheck (3-state:
  SKIPPED when feature disabled per Eng-v2 C9, OK at backlog=0, WARN
  at >10 with paste-ready remediation step via makeRemediationStep).
  Doctor query is source-scoped (Codex C2 cross-source safety) and
  matches the TERMINAL audit row (Codex C7), not any-fact-for-slug.
- src/commands/sources.ts — runAudit extended with
  facts_backfill_estimate field for cost preview.
- src/cli.ts — CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS
  + dispatch case for extract-conversation-facts.
- src/core/cycle.ts — new CyclePhase 'conversation_facts_backfill';
  PHASE_SCOPE='source' (taxonomy only per cycle.ts:131 — wrapper does
  own multi-source iteration); wired into ALL_PHASES + NEEDS_LOCK_PHASES;
  dispatch block runs between consolidate and embed.
- src/core/migrate.ts — migration v94 adds partial index
  idx_facts_extract_conversation_session ON facts(source_id, source_session)
  WHERE source LIKE 'cli:extract-conversation-facts%' so doctor query
  stays fast on million-fact brains. v14 precedent: transaction:false +
  invalid-index pre-drop on Postgres, plain CREATE INDEX on PGLite.
- src/core/schema-pack/base/gbrain-base.yaml — promote conversation
  (temporal, extractable) and atom (annotation, NOT extractable —
  atoms ARE the extracted form) into base. Flip concept.extractable:
  true semantically (cosmetic on backstop path per Codex T3; the
  original grandfather migration was solving a phantom, dropped).
  Filing rules added for both new types.
- src/core/schema-pack/base/gbrain-recommended.yaml — remove duplicate
  conversation (now inherits via extends: gbrain-base).
- src/core/types.ts — ALL_PAGE_TYPES extended with conversation, atom.
- test/extractable-pack.test.ts — updated parity gate (24 page types
  vs PR's 22; concept + conversation now extractable, atom not).
- test/schema-cli.test.ts — page-count expectation 22→24.
- VERSION + package.json bumped to 0.41.11.0.
- CHANGELOG.md release-summary in the required ELI10-first voice +
  itemized changes section.
- CLAUDE.md Key Files entry for the new modules + architecture notes.
- llms.txt + llms-full.txt regenerated.

Plan + decisions persisted at:
  ~/.claude/plans/system-instruction-you-are-working-linear-unicorn.md
CEO plan at:
  ~/.gstack/projects/garrytan-gbrain/ceo-plans/2026-05-25-conversation-retrieval-upgrade.md

Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: align v0.41.11.0 phase ordering + bump hardcoded counts after master merge

Three CI failures from the master merge in this branch:

1. test/phase-scope-coverage.test.ts pinned `ALL_PHASES.length === 19`
   and `Object.keys(PHASE_SCOPE).length === 19`. After merging master's
   v0.41 lens-packs (extract_atoms + synthesize_concepts) + my new
   conversation_facts_backfill phase, the total is 20.

2. test/core/cycle.serial.test.ts had two hardcoded `19` assertions
   (`hookCalls` and `report.phases.length`) tracking the same count.
   Both bumped to 20.

3. cycle.serial's `'default: all 6 phases run in order'` test asserts
   `report.phases.map(p => p.phase) === ALL_PHASES`. My initial commit
   put `conversation_facts_backfill` in ALL_PHASES between consolidate
   and propose_takes, but the runCycle dispatch block runs it AFTER
   the calibration trio (propose_takes / grade_takes / calibration_profile)
   and BEFORE embed. List and dispatch order didn't match, so the
   equality assertion failed.

   Resolution: moved 'conversation_facts_backfill' in ALL_PHASES to
   AFTER 'calibration_profile' so list-order matches dispatch-order.
   The dispatch block placement was correct (and remains correct);
   the list-position comment originally said "AFTER consolidate" but
   the dispatch runs it after the WHOLE consolidate→calibration_profile
   block, not just after consolidate. Comment now reflects reality.

Verified: 61/61 pass across the 3 affected test files (2.9s wallclock).

The CI logs also showed a "(unnamed) [3058.07ms]" failure in shard 1;
unable to reproduce locally (test/scripts/run-unit-parallel.test.ts
passes 6/6 in 1s). Suspected CI-load flakiness under bun's parallel
scheduler. If it persists on the next CI run, will dig in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): orphan sleep cleanup in run-unit-parallel.sh heartbeat

Two CI runs in a row reported `(fail) (unnamed) [~2400ms]` in shard 1
on this PR. Investigation:

- CI's end-of-job cleanup logged: "Terminate orphan process: pid (3344)
  (sleep)" × 6 sleep processes.
- The 6 matches exactly the 6 `runWrapper()` calls in
  test/scripts/run-unit-parallel.test.ts (1 orphan sleep per invocation).
- Each `runWrapper()` spawns scripts/run-unit-parallel.sh, which spawns
  a heartbeat function that runs `while true; do sleep 10; ...; done`
  in the background.
- The wrapper's EXIT trap was `kill "$HB_PID" 2>/dev/null` — kills the
  heartbeat shell, but its currently-running `sleep 10` child gets
  reparented to init/launchd because SIGTERM to a bash shell sleeping
  inside `sleep` doesn't propagate to the sleep child before wait
  returns. Known bash quirk on Linux.
- bun's test runner treats the orphan sleeps as a `(unnamed)` failure
  attributed to the test file that spawned the wrapper.

Fix: pkill children FIRST, then kill heartbeat. If we kill heartbeat
first, its child sleep orphans and pkill -P can no longer find it
(ppid changes to 1). Reorder applied to both the trap AND the normal
shutdown path.

Verified locally: before fix, 6 orphan sleeps after the test ran;
after fix, 0 orphan sleeps. Test still passes 6/6 in ~1s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:21:59 -07:00

462 lines
18 KiB
TypeScript

/**
* Tests for `gbrain extract-conversation-facts` — deterministic parsing,
* segmenting, rendering, checkpoint encoding, and core wiring contracts.
*
* Hermetic via __setChatTransportForTests + __setEmbedTransportForTests
* stubs so the suite stays offline. Real-LLM extraction quality is the
* job of test/eval/conversation-extraction-quality.eval.ts (env-gated).
*
* Test-isolation invariants (per CLAUDE.md R3+R4):
* - One PGLite engine per file, created in beforeAll, disposed in afterAll
* - Per-test state reset via TRUNCATE inside beforeEach (canonical pattern)
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import {
__setChatTransportForTests,
__setEmbedTransportForTests,
resetGateway,
type ChatResult,
} from '../src/core/ai/gateway.ts';
import {
parseConversationMessages,
splitIntoSegments,
renderSegmentForExtraction,
runExtractConversationFactsCore,
extractConversationFactsFingerprint,
encodeCheckpointEntry,
decodeCheckpointEntry,
DEFAULT_SEGMENT_GAP_MINUTES,
DEFAULT_SEGMENT_MAX_MESSAGES,
SEGMENT_TEXT_CHAR_LIMIT,
MAX_PAGE_BODY_BYTES,
TERMINAL_AUDIT_SOURCE,
PER_SEGMENT_SOURCE_PREFIX,
} from '../src/commands/extract-conversation-facts.ts';
// ---------------------------------------------------------------------------
// Fixture helpers.
// ---------------------------------------------------------------------------
function fmt(name: string, date: string, time: string, body: string): string {
return `**${name}** (${date} ${time}): ${body}`;
}
// ---------------------------------------------------------------------------
// parseConversationMessages — PR's 5 cases verbatim.
// ---------------------------------------------------------------------------
describe('parseConversationMessages', () => {
test('parses a single message line', () => {
const msgs = parseConversationMessages(fmt('Alice Example', '2024-03-15', '6:07 PM', 'hello'));
expect(msgs).toHaveLength(1);
expect(msgs[0].speaker).toBe('Alice Example');
expect(msgs[0].text).toBe('hello');
expect(msgs[0].timestamp).toMatch(/^2024-03-15T18:07:00Z$/);
});
test('handles AM/PM and midnight/noon', () => {
const body = [
fmt('Bob Demo', '2024-03-15', '12:00 AM', 'midnight'),
fmt('Bob Demo', '2024-03-15', '12:30 PM', 'noon'),
].join('\n');
const msgs = parseConversationMessages(body);
expect(msgs[0].timestamp).toBe('2024-03-15T00:00:00Z');
expect(msgs[1].timestamp).toBe('2024-03-15T12:30:00Z');
});
test('treats unmatched lines as continuations of the prior message', () => {
const body = [
fmt('Alice Example', '2024-03-15', '9:00 AM', 'first line'),
'still part of the first message',
fmt('Bob Demo', '2024-03-15', '9:01 AM', 'separate message'),
].join('\n');
const msgs = parseConversationMessages(body);
expect(msgs).toHaveLength(2);
expect(msgs[0].text).toBe('first line\nstill part of the first message');
expect(msgs[1].text).toBe('separate message');
});
test('ignores leading orphan lines (no anchor message yet)', () => {
const body = ['orphan one', 'orphan two', fmt('Alice Example', '2024-03-15', '9:00 AM', 'real')].join('\n');
const msgs = parseConversationMessages(body);
expect(msgs).toHaveLength(1);
expect(msgs[0].text).toBe('real');
});
test('empty body returns empty array', () => {
expect(parseConversationMessages('')).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// splitIntoSegments — PR's 5 cases verbatim plus tuning regression.
// ---------------------------------------------------------------------------
describe('splitIntoSegments', () => {
test('cuts on time gap larger than gapMinutes', () => {
const msgs = parseConversationMessages([
fmt('Alice Example', '2024-03-15', '9:00 AM', 'a'),
fmt('Bob Demo', '2024-03-15', '9:05 AM', 'b'),
// Gap of 90 minutes > default 30 → new segment.
fmt('Alice Example', '2024-03-15', '10:35 AM', 'c'),
fmt('Bob Demo', '2024-03-15', '10:36 AM', 'd'),
].join('\n'));
const segs = splitIntoSegments(msgs);
expect(segs).toHaveLength(2);
expect(segs[0].messages).toHaveLength(2);
expect(segs[1].messages).toHaveLength(2);
});
test('cuts when segment reaches maxMessages cap', () => {
const lines: string[] = [];
for (let i = 0; i < 7; i++) {
const mm = String(i).padStart(2, '0');
lines.push(fmt('Alice Example', '2024-03-15', `9:${mm} AM`, `msg ${i}`));
}
const msgs = parseConversationMessages(lines.join('\n'));
const segs = splitIntoSegments(msgs, { maxMessages: 3 });
// 7 messages / 3 per segment → 2 full + 1 leftover (dropped: <2 messages).
expect(segs.length).toBeGreaterThanOrEqual(2);
for (const s of segs) expect(s.messages.length).toBeLessThanOrEqual(3);
});
test('drops segments shorter than the minimum', () => {
const msgs = parseConversationMessages(
fmt('Alice Example', '2024-03-15', '9:00 AM', 'only one'),
);
expect(splitIntoSegments(msgs)).toHaveLength(0);
});
test('participants array preserves first-seen order', () => {
const msgs = parseConversationMessages([
fmt('Bob Demo', '2024-03-15', '9:00 AM', 'b1'),
fmt('Alice Example', '2024-03-15', '9:05 AM', 'a1'),
fmt('Bob Demo', '2024-03-15', '9:06 AM', 'b2'),
].join('\n'));
const segs = splitIntoSegments(msgs);
expect(segs[0].participants).toEqual(['Bob Demo', 'Alice Example']);
});
test('sinceIso filters out messages older than the watermark', () => {
const msgs = parseConversationMessages([
fmt('Alice Example', '2024-03-15', '9:00 AM', 'old'),
fmt('Bob Demo', '2024-03-15', '9:05 AM', 'old'),
fmt('Alice Example', '2024-03-16', '9:00 AM', 'new'),
fmt('Bob Demo', '2024-03-16', '9:05 AM', 'new'),
].join('\n'));
const segs = splitIntoSegments(msgs, { sinceIso: '2024-03-15T23:00:00Z' });
expect(segs).toHaveLength(1);
expect(segs[0].startIso).toBe('2024-03-16T09:00:00Z');
});
test('tuned defaults: 30/30 (Eng-v2 T5)', () => {
expect(DEFAULT_SEGMENT_GAP_MINUTES).toBe(30);
expect(DEFAULT_SEGMENT_MAX_MESSAGES).toBe(30);
expect(SEGMENT_TEXT_CHAR_LIMIT).toBe(6500);
});
});
// ---------------------------------------------------------------------------
// renderSegmentForExtraction.
// ---------------------------------------------------------------------------
describe('renderSegmentForExtraction', () => {
test('prepends topical/temporal context header', () => {
const msgs = parseConversationMessages([
fmt('Alice Example', '2024-03-15', '9:00 AM', 'hello'),
fmt('Bob Demo', '2024-03-15', '9:05 AM', 'hi back'),
].join('\n'));
const seg = splitIntoSegments(msgs)[0];
const text = renderSegmentForExtraction('imessage: Alice Example', seg);
expect(text).toContain('Page: imessage: Alice Example');
expect(text).toContain('Conversation between Alice Example and Bob Demo');
expect(text).toContain('2024-03-15T09:00:00Z');
expect(text).toContain('2024-03-15T09:05:00Z');
});
test('truncates oversize segments but keeps the header intact', () => {
const big = Array.from({ length: 500 }, (_, i) => {
const mm = String(i % 60).padStart(2, '0');
const hh = String(9 + Math.floor(i / 60)).padStart(2, '0');
return `**Alice Example** (2024-03-15 ${hh}:${mm} AM): ${'x'.repeat(50)}`;
}).join('\n');
const msgs = parseConversationMessages(big);
const seg = splitIntoSegments(msgs, { maxMessages: 500 })[0];
const text = renderSegmentForExtraction('big-page', seg);
expect(text.length).toBeLessThanOrEqual(SEGMENT_TEXT_CHAR_LIMIT + 32);
expect(text.startsWith('Page: big-page')).toBe(true);
expect(text).toContain('Conversation between');
});
});
// ---------------------------------------------------------------------------
// Fingerprint + checkpoint encoding.
// ---------------------------------------------------------------------------
describe('extractConversationFactsFingerprint (Eng-v2 A3)', () => {
test('same sourceId yields same fingerprint', () => {
expect(extractConversationFactsFingerprint({ sourceId: 'default' }))
.toBe(extractConversationFactsFingerprint({ sourceId: 'default' }));
});
test('different sourceId yields different fingerprint', () => {
expect(extractConversationFactsFingerprint({ sourceId: 'a' }))
.not.toBe(extractConversationFactsFingerprint({ sourceId: 'b' }));
});
});
describe('checkpoint entry encoding', () => {
test('round-trips sourceId | slug | iso', () => {
const entry = encodeCheckpointEntry('default', 'conversations/imessage/alice-example', '2024-03-16T08:05:00Z');
const decoded = decodeCheckpointEntry(entry);
expect(decoded).toEqual({
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
endIso: '2024-03-16T08:05:00Z',
});
});
test('decodes null for malformed entries', () => {
expect(decodeCheckpointEntry('no-pipes-here')).toBeNull();
expect(decodeCheckpointEntry('only-one|pipe')).toBeNull();
});
test('slug with forward slashes survives encoding (no pipe collision)', () => {
const entry = encodeCheckpointEntry('src-a', 'conversations/group/2024/march/team-x', '2024-03-16T08:05:00Z');
const decoded = decodeCheckpointEntry(entry);
expect(decoded?.slug).toBe('conversations/group/2024/march/team-x');
});
});
// ---------------------------------------------------------------------------
// runExtractConversationFactsCore — engine-wired contract tests.
// ---------------------------------------------------------------------------
const SAMPLE_BODY = [
fmt('Alice Example', '2024-03-15', '9:00 AM', 'Hi, I just signed the offer letter for Acme Corp.'),
fmt('Bob Demo', '2024-03-15', '9:01 AM', "Congrats! What's the title?"),
fmt('Alice Example', '2024-03-15', '9:02 AM', 'Staff engineer on the platform team.'),
fmt('Bob Demo', '2024-03-15', '9:03 AM', 'Nice.'),
// Big time gap → new segment.
fmt('Alice Example', '2024-03-16', '8:00 AM', 'Update: I started at Acme Corp this morning.'),
fmt('Bob Demo', '2024-03-16', '8:05 AM', 'Day one! How is it?'),
].join('\n');
describe('runExtractConversationFactsCore', () => {
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Deterministic chat-transport stub. Records calls + returns one
// fact per turn. Real-LLM extraction quality is the eval suite's job.
let callIndex = 0;
__setChatTransportForTests(async (): Promise<ChatResult> => {
callIndex++;
return {
text: JSON.stringify({
facts: [{
fact: `synthetic fact #${callIndex}`,
kind: 'event',
entity: 'companies/acme-corp',
confidence: 1.0,
notability: 'high',
}],
}),
blocks: [],
stopReason: 'end',
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 0,
cache_creation_tokens: 0,
},
model: 'stub:stub',
providerId: 'stub',
};
});
// Deterministic embedding stub.
__setEmbedTransportForTests(
(async () => ({
embeddings: [Array.from({ length: 1536 }, () => 0.1)],
})) as never,
);
});
afterAll(async () => {
__setChatTransportForTests(null);
__setEmbedTransportForTests(null);
resetGateway();
await engine.disconnect();
});
beforeEach(async () => {
// Clean state per test. Use executeRaw because PGLite uses different
// truncation semantics than the canonical reset helper.
await engine.executeRaw(`DELETE FROM facts WHERE source LIKE 'cli:extract-conversation-facts%'`);
await engine.executeRaw(`DELETE FROM op_checkpoints WHERE op = 'extract-conversation-facts'`);
await engine.executeRaw(`DELETE FROM pages WHERE slug LIKE 'conversations/%' OR slug LIKE 'people/alice%'`);
// Set facts.extraction_enabled=true so kill-switch doesn't refuse.
await engine.setConfig('facts.extraction_enabled', 'true');
// Seed test pages.
await engine.putPage('conversations/imessage/alice-example', {
type: 'conversation',
title: 'iMessage: Alice Example',
compiled_truth: SAMPLE_BODY,
timeline: '',
frontmatter: {},
});
await engine.putPage('people/alice-example', {
type: 'person',
title: 'Alice Example',
compiled_truth: 'Profile content for Alice Example.',
timeline: '',
frontmatter: {},
});
});
test('dry-run reports segmentation without writing facts', async () => {
const result = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
dryRun: true,
sleepMs: 0,
});
expect(result.pages_considered).toBe(1);
expect(result.pages_processed).toBe(1);
expect(result.facts_inserted).toBe(0);
expect(result.segments_processed).toBeGreaterThanOrEqual(1);
});
test('non-conversation pages are skipped', async () => {
const result = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'people/alice-example',
dryRun: true,
sleepMs: 0,
});
// pages_considered counts only pages whose type matches the allowlist.
expect(result.pages_considered).toBe(0);
});
test('sinceIso filters already-processed history', async () => {
const result = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
dryRun: true,
sleepMs: 0,
sinceIso: '2099-01-01T00:00:00Z',
});
expect(result.pages_processed).toBe(0);
expect(result.pages_skipped).toBe(1);
});
test('writes facts with per-segment source_session AND terminal audit row (E16)', async () => {
const result = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
sleepMs: 0,
});
expect(result.pages_processed).toBe(1);
expect(result.facts_inserted).toBeGreaterThan(0);
// Per-segment facts present.
const perSegFacts = await engine.executeRaw<{ count: string | number }>(
`SELECT COUNT(*) AS count FROM facts WHERE source = $1 AND source_session = $2`,
[PER_SEGMENT_SOURCE_PREFIX, `${PER_SEGMENT_SOURCE_PREFIX}:conversations/imessage/alice-example`],
);
expect(Number(perSegFacts[0]?.count ?? 0)).toBeGreaterThan(0);
// Terminal audit row present.
const terminalRows = await engine.executeRaw<{ count: string | number }>(
`SELECT COUNT(*) AS count FROM facts WHERE source = $1 AND source_session = $2`,
[TERMINAL_AUDIT_SOURCE, `${TERMINAL_AUDIT_SOURCE}:conversations/imessage/alice-example`],
);
expect(Number(terminalRows[0]?.count ?? 0)).toBe(1);
});
test('row_num accumulator: segment 2 facts start after segment 1 (Codex C1)', async () => {
await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
sleepMs: 0,
});
const rows = await engine.executeRaw<{ row_num: number }>(
`SELECT row_num FROM facts
WHERE source = $1 AND source_markdown_slug = $2
ORDER BY row_num ASC`,
[PER_SEGMENT_SOURCE_PREFIX, 'conversations/imessage/alice-example'],
);
// Each row_num must be unique (no per-segment collision on row 0).
const nums = rows.map((r) => Number(r.row_num));
expect(new Set(nums).size).toBe(nums.length);
// Strictly monotonic + zero-based.
for (let i = 0; i < nums.length; i++) {
expect(nums[i]).toBe(i);
}
});
test('--force clears resume entry, allowing re-run', async () => {
const first = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
sleepMs: 0,
});
expect(first.pages_processed).toBe(1);
// Re-run without force: no new segments (sinceIso > newest segment endIso).
const second = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
sleepMs: 0,
});
expect(second.pages_skipped).toBe(1);
// Re-run with force: re-processes.
const third = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
sleepMs: 0,
force: true,
});
expect(third.pages_processed).toBe(1);
expect(third.segments_processed).toBeGreaterThanOrEqual(1);
});
test('honors facts.extraction_enabled kill-switch (F2)', async () => {
await engine.setConfig('facts.extraction_enabled', 'false');
await expect(
runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
sleepMs: 0,
}),
).rejects.toThrow(/extraction_enabled=false/);
});
test('--override-disabled bypasses kill-switch', async () => {
await engine.setConfig('facts.extraction_enabled', 'false');
const result = await runExtractConversationFactsCore(engine, {
sourceId: 'default',
slug: 'conversations/imessage/alice-example',
sleepMs: 0,
overrideDisabled: true,
});
expect(result.pages_processed).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Body cap (Eng A2 / E17) — pin the cap constant; integration via reads
// in seeded huge pages would require >25MB fixture, not viable in unit suite.
// ---------------------------------------------------------------------------
describe('body cap constant (Eng A2)', () => {
test('MAX_PAGE_BODY_BYTES is 25MB', () => {
expect(MAX_PAGE_BODY_BYTES).toBe(25 * 1024 * 1024);
});
});