mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
167 lines
6.1 KiB
TypeScript
167 lines
6.1 KiB
TypeScript
// v0.41.19.0 — T4 of ops-fix-wave.
|
|
//
|
|
// Pins that extract_atoms wires its progress reporter inside the work
|
|
// loop (one tick per processed item) and emits a heartbeat before the
|
|
// batch idempotency check. Codex caught that cycle.ts must NOT pass a
|
|
// child reporter — phases receive the SAME reporter and only call tick
|
|
// / heartbeat (cycle.ts owns start / finish).
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { runPhaseExtractAtoms } from '../../src/core/cycle/extract-atoms.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
import type { ProgressReporter } from '../../src/core/progress.ts';
|
|
import type { ChatResult, ChatOpts } from '../../src/core/ai/gateway.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
function makeMockReporter(): {
|
|
reporter: ProgressReporter;
|
|
events: Array<{ kind: 'start' | 'tick' | 'heartbeat' | 'finish' | 'child'; phase?: string; note?: string; n?: number }>;
|
|
} {
|
|
const events: Array<{ kind: 'start' | 'tick' | 'heartbeat' | 'finish' | 'child'; phase?: string; note?: string; n?: number }> = [];
|
|
const reporter: ProgressReporter = {
|
|
start: (phase, _total) => { events.push({ kind: 'start', phase }); },
|
|
tick: (n, note) => { events.push({ kind: 'tick', n, note }); },
|
|
heartbeat: (note) => { events.push({ kind: 'heartbeat', note }); },
|
|
finish: (note) => { events.push({ kind: 'finish', note }); },
|
|
child: (phase) => {
|
|
events.push({ kind: 'child', phase });
|
|
return reporter; // return self for simplicity
|
|
},
|
|
};
|
|
return { reporter, events };
|
|
}
|
|
|
|
function stubChat(text: string): (o: ChatOpts) => Promise<ChatResult> {
|
|
return async (_o: ChatOpts) => ({
|
|
text,
|
|
blocks: [{ type: 'text', text }],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-haiku-4-5',
|
|
providerId: 'anthropic',
|
|
});
|
|
}
|
|
|
|
describe('extract_atoms progress wiring (T4)', () => {
|
|
test('phase does NOT call start or finish — cycle.ts owns those', async () => {
|
|
const { reporter, events } = makeMockReporter();
|
|
const validAtomJson = JSON.stringify([
|
|
{ title: 'A', atom_type: 'insight', body: 'body a' },
|
|
]);
|
|
await runPhaseExtractAtoms(engine, {
|
|
sourceId: 'default',
|
|
_transcripts: [
|
|
{ filePath: '/tmp/t1.txt', content: 'transcript 1 body', contentHash: 'h1'.repeat(8) },
|
|
],
|
|
_pages: [],
|
|
_chat: stubChat(validAtomJson),
|
|
progress: reporter,
|
|
});
|
|
const startEvents = events.filter(e => e.kind === 'start');
|
|
const finishEvents = events.filter(e => e.kind === 'finish');
|
|
expect(startEvents.length).toBe(0);
|
|
expect(finishEvents.length).toBe(0);
|
|
});
|
|
|
|
test('emits a heartbeat before the batch idempotency check', async () => {
|
|
const { reporter, events } = makeMockReporter();
|
|
await runPhaseExtractAtoms(engine, {
|
|
sourceId: 'default',
|
|
_transcripts: [
|
|
{ filePath: '/tmp/t1.txt', content: 'transcript', contentHash: 'h1'.repeat(8) },
|
|
],
|
|
_pages: [],
|
|
_chat: stubChat('[]'),
|
|
progress: reporter,
|
|
});
|
|
const heartbeats = events.filter(e => e.kind === 'heartbeat');
|
|
expect(heartbeats.length).toBeGreaterThanOrEqual(1);
|
|
// Note mentions the count
|
|
expect(heartbeats[0].note).toMatch(/checking existing atoms/);
|
|
});
|
|
|
|
test('one tick per processed work item with running count note', async () => {
|
|
const { reporter, events } = makeMockReporter();
|
|
const validAtomJson = JSON.stringify([
|
|
{ title: 'A', atom_type: 'insight', body: 'body a' },
|
|
]);
|
|
await runPhaseExtractAtoms(engine, {
|
|
sourceId: 'default',
|
|
_transcripts: [
|
|
{ filePath: '/tmp/t1.txt', content: 'a', contentHash: 'h1'.repeat(8) },
|
|
{ filePath: '/tmp/t2.txt', content: 'b', contentHash: 'h2'.repeat(8) },
|
|
{ filePath: '/tmp/t3.txt', content: 'c', contentHash: 'h3'.repeat(8) },
|
|
],
|
|
_pages: [],
|
|
_chat: stubChat(validAtomJson),
|
|
progress: reporter,
|
|
});
|
|
const ticks = events.filter(e => e.kind === 'tick');
|
|
expect(ticks.length).toBe(3);
|
|
expect(ticks[0].note).toMatch(/atoms.*skipped/);
|
|
});
|
|
|
|
test('passes an explicit Haiku model to chat calls', async () => {
|
|
const seenModels: Array<string | undefined> = [];
|
|
const validAtomJson = JSON.stringify([
|
|
{ title: 'A', atom_type: 'insight', body: 'body a' },
|
|
]);
|
|
await runPhaseExtractAtoms(engine, {
|
|
sourceId: 'default',
|
|
_transcripts: [
|
|
{ filePath: '/tmp/t1.txt', content: 'transcript 1 body', contentHash: 'h1'.repeat(8) },
|
|
],
|
|
_pages: [],
|
|
_chat: async (o: ChatOpts) => {
|
|
seenModels.push(o.model);
|
|
return stubChat(validAtomJson)(o);
|
|
},
|
|
});
|
|
expect(seenModels).toEqual(['anthropic:claude-haiku-4-5']);
|
|
});
|
|
|
|
test('DB config can override the extract_atoms budget and model', async () => {
|
|
await engine.setConfig('models.dream.extract_atoms', 'anthropic:claude-haiku-4-5-20251001');
|
|
await engine.setConfig('cycle.extract_atoms.budget_usd', '0.12');
|
|
const validAtomJson = JSON.stringify([
|
|
{ title: 'A', atom_type: 'insight', body: 'body a' },
|
|
]);
|
|
const result = await runPhaseExtractAtoms(engine, {
|
|
sourceId: 'default',
|
|
_transcripts: [
|
|
{ filePath: '/tmp/t1.txt', content: 'transcript 1 body', contentHash: 'h1'.repeat(8) },
|
|
],
|
|
_pages: [],
|
|
_chat: stubChat(validAtomJson),
|
|
});
|
|
expect(result.details.model).toBe('anthropic:claude-haiku-4-5-20251001');
|
|
expect(result.details.budget_usd).toBe(0.12);
|
|
});
|
|
|
|
test('no progress wiring required — opts.progress is optional', async () => {
|
|
// Sanity: phase works without a reporter.
|
|
const result = await runPhaseExtractAtoms(engine, {
|
|
sourceId: 'default',
|
|
_transcripts: [],
|
|
_pages: [],
|
|
});
|
|
expect(result.phase).toBe('extract_atoms');
|
|
});
|
|
});
|