Files
gbrain/test/chronicle-extract.test.ts
T
1833d95896 fix(dream,chronicle): synthesize/concepts output family — source scope, output root, retrieval reach, durable provenance, honest judge failures (#1586 #2415 #2163 #2569 #2606) (#2939)
Five verified-open fixes to the dream/synthesize output family:

- #1586: thread the cycle's resolved sourceId (cycleSourceId) through
  runPhaseSynthesize -> SubagentHandlerData.source_id -> subagent tool
  OperationContext, and stamp collected refs + summary page with the same
  source, so synthesized pages stop landing in 'default'.
- #2415: new config knob dream.synthesize.output_root (default 'wiki',
  zero behavior change unless set) drives the synthesize prompt slug
  templates, the patterns reflection lookup + prompt, and remaps the
  filing-rule allow-list globs. Registered in KNOWN_CONFIG_KEYS.
- #2163: synthesize_concepts writes concept pages through
  importFromContent (put_page's parse->chunk->embed pipeline) instead of
  bare engine.putPage, so concepts/ pages are chunked + embedded and
  reachable by retrieval.
- #2569: stampDreamProvenance persists dream_generated + dream_cycle_date
  into pages.frontmatter (JSONB merge via executeRawJsonb) at write time,
  so generated pages are DB-queryable and put_page write-through can't
  erase the marker.
- #2606: chronicle judge detects stopReason 'length' truncation and
  no-JSON-array parse failures as distinct skipped reasons
  (judge_truncated / judge_parse_failed) instead of a false terminal
  no_events; output cap raised to 4000 and configurable via
  chronicle.judge_max_tokens.

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-17 15:01:27 -07:00

178 lines
8.5 KiB
TypeScript

/**
* v0.42.x — Life Chronicle (#2390) auto-emit extractor (Phase A.3).
* PGLite in-memory. Covers eligibility, the extractor's parse barrier +
* idempotent writes (event pages + timeline projection), and the backstop's
* auto_chronicle gating + enqueue. The LLM judge is stubbed so the deterministic
* write path is tested without a gateway.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { isChronicleEligible } from '../src/core/chronicle/eligibility.ts';
import { runChronicleExtract, parseJudgeJson, type ChronicleJudge } from '../src/core/chronicle/extract-events.ts';
import { runChronicleBackstop } from '../src/core/chronicle/backstop.ts';
let engine: PGLiteEngine;
const LONG_BODY = 'A'.repeat(120);
async function countEvents(): Promise<number> {
const r = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM pages WHERE type = 'event'`);
return Number(r[0].n);
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
describe('isChronicleEligible', () => {
const body = LONG_BODY;
test('meeting is eligible', () => {
expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body }).ok).toBe(true);
});
test('meetings/ slug rescues a note-typed page', () => {
expect(isChronicleEligible({ type: 'note', slug: 'meetings/x', body }).ok).toBe(true);
});
test('diary is excluded (privacy)', () => {
expect(isChronicleEligible({ type: 'diary', slug: 'life/diary/x', body })).toEqual({ ok: false, reason: 'diary_excluded' });
});
test('event is excluded (anti-loop)', () => {
expect(isChronicleEligible({ type: 'event', slug: 'life/events/x', body })).toEqual({ ok: false, reason: 'event_self' });
});
test('dream-generated is excluded', () => {
expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body, dreamGenerated: true })).toEqual({ ok: false, reason: 'dream_generated' });
});
test('too-short body is excluded', () => {
expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body: 'hi' })).toEqual({ ok: false, reason: 'too_short' });
});
test('unrelated type is excluded', () => {
expect(isChronicleEligible({ type: 'concept', slug: 'wiki/concepts/x', body })).toEqual({ ok: false, reason: 'kind:concept' });
});
});
describe('runChronicleExtract', () => {
const oneEvent: ChronicleJudge = async () => ({
events: [{ when: '2026-06-18T15:30:00Z', who: ['people/sarah-chen'], what: 'Sarah committed to Q3', kind: 'commitment' }],
});
beforeEach(async () => {
await engine.executeRaw('DELETE FROM timeline_entries');
await engine.executeRaw(`DELETE FROM pages WHERE type = 'event' OR slug = 'meetings/2026-06-18-sync'`);
await engine.putPage('meetings/2026-06-18-sync', {
type: 'meeting', title: 'Weekly sync',
compiled_truth: LONG_BODY,
frontmatter: { attendees: ['people/sarah-chen'] },
effective_date: new Date('2026-06-18T15:00:00Z'),
});
});
test('writes an event page + timeline projection', async () => {
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent });
expect(r.status).toBe('extracted');
expect(r.events_written).toBe(1);
expect(await countEvents()).toBe(1);
const day = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' });
expect(day.length).toBe(1);
expect(day[0].summary).toBe('Sarah committed to Q3');
expect(day[0].page_slug).toBe('meetings/2026-06-18-sync'); // projection keyed to depth
expect(day[0].event_slug?.startsWith('life/events/2026-06-18-')).toBe(true);
expect(day[0].kind).toBe('commitment');
});
test('is idempotent: running twice yields one event + one projection', async () => {
await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent });
await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent });
expect(await countEvents()).toBe(1);
const day = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' });
expect(day.length).toBe(1);
});
test('parse barrier: a malformed proposal writes NOTHING', async () => {
const before = await countEvents();
const bad: ChronicleJudge = async () => ({ events: [{ when: '2026-06-18', who: [], kind: 'x' } as never] });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: bad });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('malformed_proposal');
expect(await countEvents()).toBe(before); // no partial write
});
test('parse barrier: a non-date `when` writes NOTHING (codex fix #2)', async () => {
const before = await countEvents();
const badDate: ChronicleJudge = async () => ({ events: [{ when: 'not-a-date', who: [], what: 'x', kind: 'meeting' }] });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: badDate });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('malformed_proposal');
expect(await countEvents()).toBe(before);
});
test('no events → no_events status', async () => {
const none: ChronicleJudge = async () => ({ events: [] });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: none });
expect(r.status).toBe('no_events');
});
// #2606: a truncated or unparseable judge response must NOT be recorded as
// a legitimate no_events — it gets a distinct skipped reason.
test('truncated judge output → skipped/judge_truncated, not no_events (#2606)', async () => {
const truncated: ChronicleJudge = async () => ({ events: [], failure: 'truncated' });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: truncated });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('judge_truncated');
expect(await countEvents()).toBe(0);
});
test('unparseable judge output → skipped/judge_parse_failed (#2606)', async () => {
const parseFailed: ChronicleJudge = async () => ({ events: [], failure: 'parse_failed' });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: parseFailed });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('judge_parse_failed');
});
});
describe('parseJudgeJson failure signalling (#2606)', () => {
test('a legitimate empty array parses to []', () => {
expect(parseJudgeJson('[]')).toEqual([]);
expect(parseJudgeJson('```json\n[]\n```')).toEqual([]);
});
test('a valid array round-trips', () => {
const arr = parseJudgeJson('[{"when":"2026-06-18","who":[],"what":"x","kind":"meeting"}]');
expect(Array.isArray(arr)).toBe(true);
expect(arr!.length).toBe(1);
});
test('empty / no-array / truncated / non-array responses return null', () => {
expect(parseJudgeJson('')).toBeNull();
expect(parseJudgeJson('I found no events worth extracting.')).toBeNull();
// Truncated mid-array (the maxTokens-cap shape from the issue).
expect(parseJudgeJson('[{"when":"2026-06-18","who":["a"],"what":"long ev')).toBeNull();
expect(parseJudgeJson('{"events": 1}')).toBeNull();
});
});
describe('runChronicleBackstop gating', () => {
beforeEach(async () => {
await engine.unsetConfig('auto_chronicle');
await engine.putPage('meetings/bs', { type: 'meeting', title: 'bs', compiled_truth: LONG_BODY });
});
test('skips when auto_chronicle is off (default)', async () => {
const r = await runChronicleBackstop({ slug: 'meetings/bs', type: 'meeting', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' });
expect(r).toEqual({ enqueued: false, skipped: 'auto_chronicle_off' });
});
test('skips a diary page before consulting the flag', async () => {
const r = await runChronicleBackstop({ slug: 'life/diary/x', type: 'diary', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' });
expect(r).toEqual({ enqueued: false, skipped: 'diary_excluded' });
});
test('enqueues a chronicle_extract job when enabled + eligible', async () => {
await engine.setConfig('auto_chronicle', 'true');
const r = await runChronicleBackstop({ slug: 'meetings/bs', type: 'meeting', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' });
expect(r.enqueued).toBe(true);
const jobs = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM minion_jobs WHERE name = 'chronicle_extract'`);
expect(Number(jobs[0].n)).toBeGreaterThanOrEqual(1);
});
});