import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { MindDB, AwarenessLayer, type Embedder } from '@waggle/core'; import { runLoopTick, parseLoopSpec, parseProposal, buildMakerPrompt, LOOP_MIN_INTERVAL_MS, type LoopSchedule, type LoopLogger, } from '../../src/local/loop-executor.js'; // A chat stub that answers the judge prompt with valid rubric JSON and any // other (maker) prompt with a plain report. buildPrompt() appends // "Return the JSON now." to the judge call, which is our discriminator. function makeChat(report = 'Report: nothing materially new since the last run.') { return vi.fn(async (prompt: string) => { if (prompt.includes('Return the JSON now')) { return '{"correctness": 8, "procedure": 7, "conciseness": 9, "feedback": "ok"}'; } return report; }); } // Minimal embedder stub. Recall is best-effort in runLoopTick (wrapped in // try/catch), so even if the vector path rejects, the tick proceeds. const embedder = { dimensions: 384, embed: async () => new Float32Array(384), embedBatch: async (texts: string[]) => texts.map(() => new Float32Array(384)), getActiveProvider: () => 'stub', getStatus: () => ({ modelName: 'stub' }), } as unknown as Embedder; const silentLog: LoopLogger = { info: () => {}, warn: () => {} }; function framesIn(db: MindDB, gop: string): Array<{ content: string; source: string }> { return db.getDatabase() .prepare('SELECT content, source FROM memory_frames WHERE gop_id = ?') .all(gop) as Array<{ content: string; source: string }>; } /** Seed a prior-tick awareness row so the cost floor has a lastTickAt to read. */ function seedPriorTick(db: MindDB, scheduleId: number, lastTickAt: string) { new AwarenessLayer(db).add('pending', `Loop ${scheduleId}`, 0, undefined, { status: `loop:${scheduleId}`, result: 'previous report', lastTickAt, }); } describe('runLoopTick (Loop v0 — L1 report-only)', () => { let tmpDir: string; let db: MindDB; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-loop-')); db = new MindDB(path.join(tmpDir, 'test.mind')); }); afterEach(() => { db.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); function schedule(overrides?: Partial): LoopSchedule { return { id: 1, name: 'Test Loop', job_config: JSON.stringify({ prompt: 'Observe the pipeline.' }), last_run_at: null, ...overrides, }; } it('writes a report frame to the loop gop with source agent_inferred', async () => { const chat = makeChat(); const res = await runLoopTick({ schedule: schedule(), mindDb: db, embedder, chat, log: silentLog }); expect(res.skipped).toBeFalsy(); expect(res.wrote).toBe(true); const frames = framesIn(db, 'loop'); expect(frames).toHaveLength(1); expect(frames[0].source).toBe('agent_inferred'); expect(frames[0].content).toContain('Report: nothing materially new'); }); it('records the judge score and surfaces it in the result', async () => { const res = await runLoopTick({ schedule: schedule(), mindDb: db, embedder, chat: makeChat(), log: silentLog }); // weighted 0.8*0.5 + 0.7*0.3 + 0.9*0.2 = 0.79 (length penalty 1 for a short report) expect(res.score).toBeCloseTo(0.79, 2); }); it('persists cross-tick state and feeds the prior report into the next maker prompt', async () => { // Disable the cost floor so the back-to-back ticks both run (the first stamps // lastTickAt = now, which would otherwise throttle the second). const noFloor = JSON.stringify({ prompt: 'Observe the pipeline.', minIntervalMs: 0 }); const chat1 = makeChat('First run: 3 deals at risk.'); await runLoopTick({ schedule: schedule({ job_config: noFloor }), mindDb: db, embedder, chat: chat1, log: silentLog }); // Prior state lives under the namespaced status key. const items = new AwarenessLayer(db).getByStatus('loop:1'); expect(items).toHaveLength(1); expect(new AwarenessLayer(db).parseMetadata(items[0]).result).toContain('First run: 3 deals at risk'); // Second tick: the maker prompt must carry the prior report. const chat2 = makeChat('Second run: 1 new at-risk deal.'); await runLoopTick({ schedule: schedule({ job_config: noFloor }), mindDb: db, embedder, chat: chat2, log: silentLog }); const makerCall = chat2.mock.calls.find(c => !String(c[0]).includes('Return the JSON now')); expect(makerCall?.[0]).toContain('First run: 3 deals at risk'); // State merged in place (still a single awareness item), result advanced. const after = new AwarenessLayer(db).getByStatus('loop:1'); expect(after).toHaveLength(1); expect(new AwarenessLayer(db).parseMetadata(after[0]).result).toContain('Second run'); }); it('still reports when the judge output is unparseable (score undefined, no throw)', async () => { // Judge returns junk → LLMJudge yields parsed:false, score stays undefined. const chat = vi.fn(async () => 'not json at all'); const res = await runLoopTick({ schedule: schedule(), mindDb: db, embedder, chat, log: silentLog }); expect(res.skipped).toBeFalsy(); expect(res.score).toBeUndefined(); expect(framesIn(db, 'loop')).toHaveLength(1); // the report itself is "not json at all" — still written }); it('throttles a tick whose last real run (awareness lastTickAt) is within the cost floor', async () => { const chat = makeChat(); seedPriorTick(db, 1, new Date(Date.now() - 60_000).toISOString()); // ran 1 min ago const res = await runLoopTick({ schedule: schedule(), mindDb: db, embedder, chat, log: silentLog }); expect(res.skipped).toBe(true); expect(res.reason).toBe('within min interval'); expect(chat).not.toHaveBeenCalled(); expect(framesIn(db, 'loop')).toHaveLength(0); }); it('runs when the last real run is older than the cost floor', async () => { const chat = makeChat(); seedPriorTick(db, 1, new Date(Date.now() - (LOOP_MIN_INTERVAL_MS + 60_000)).toISOString()); const res = await runLoopTick({ schedule: schedule(), mindDb: db, embedder, chat, log: silentLog }); expect(res.skipped).toBeFalsy(); expect(chat).toHaveBeenCalled(); }); it('does NOT throttle on schedule.last_run_at — only on awareness lastTickAt (TZ-safe regression)', async () => { // The scheduler rewrites last_run_at via markRun on every tick (incl. skips) // in SQLite's space format, which V8 parses as local time — so it must never // drive the floor. With no prior awareness lastTickAt, the loop runs even // when last_run_at looks "just now". const chat = makeChat(); const justNowSqlite = new Date().toISOString().slice(0, 19).replace('T', ' '); // 'YYYY-MM-DD HH:MM:SS' const res = await runLoopTick({ schedule: schedule({ last_run_at: justNowSqlite }), mindDb: db, embedder, chat, log: silentLog }); expect(res.skipped).toBeFalsy(); expect(chat).toHaveBeenCalled(); }); it('skips a loop with no prompt', async () => { const chat = makeChat(); const res = await runLoopTick({ schedule: schedule({ job_config: '{}' }), mindDb: db, embedder, chat, log: silentLog, }); expect(res.skipped).toBe(true); expect(res.reason).toBe('no prompt'); expect(chat).not.toHaveBeenCalled(); }); it('does not write to memory when writeToMemory is false', async () => { const chat = makeChat(); const res = await runLoopTick({ schedule: schedule({ job_config: JSON.stringify({ prompt: 'observe', writeToMemory: false }) }), mindDb: db, embedder, chat, log: silentLog, }); expect(res.skipped).toBeFalsy(); expect(res.wrote).toBeFalsy(); expect(framesIn(db, 'loop')).toHaveLength(0); }); }); describe('parseLoopSpec', () => { it('returns null on missing/blank prompt', () => { expect(parseLoopSpec('{}')).toBeNull(); expect(parseLoopSpec('{"prompt":" "}')).toBeNull(); expect(parseLoopSpec('not json')).toBeNull(); }); it('defaults query to prompt and writeToMemory to true', () => { const spec = parseLoopSpec('{"prompt":"do X"}'); expect(spec).toMatchObject({ prompt: 'do X', query: 'do X', writeToMemory: true, minIntervalMs: LOOP_MIN_INTERVAL_MS }); }); it('honors explicit query, rubric, writeToMemory and minIntervalMs', () => { const spec = parseLoopSpec('{"prompt":"p","query":"q","rubric":"r","writeToMemory":false,"minIntervalMs":1000}'); expect(spec).toMatchObject({ prompt: 'p', query: 'q', rubric: 'r', writeToMemory: false, minIntervalMs: 1000 }); }); }); describe('buildMakerPrompt', () => { it('omits prior/recalled sections when empty and includes the report-only instruction', () => { const p = buildMakerPrompt({ name: 'L', prompt: 'task', priorResult: '', recalled: '' }); expect(p).toContain('task'); expect(p).not.toContain('previous run:'); expect(p).toContain('do not take any action'); }); it('includes prior result and recalled context when present', () => { const p = buildMakerPrompt({ name: 'L', prompt: 'task', priorResult: 'PRIOR', recalled: 'RECALL' }); expect(p).toContain('PRIOR'); expect(p).toContain('RECALL'); }); it('assist mode swaps the report-only line for a proposal instruction', () => { const p = buildMakerPrompt({ name: 'L', prompt: 'task', priorResult: '', recalled: '', assist: true }); expect(p).toContain('propose exactly ONE action'); expect(p).toContain('a human reviews'); expect(p).not.toContain('do not take any action'); }); }); describe('parseProposal', () => { it('extracts the last json fence as a proposed action', () => { const out = 'Report text.\n```json\n{"tool":"send_email","args":{"to":"a@b.c"},"summary":"follow up"}\n```'; expect(parseProposal(out)).toEqual({ tool: 'send_email', args: { to: 'a@b.c' }, summary: 'follow up' }); }); it('returns null for no fence, {none:true}, malformed JSON, or a missing tool', () => { expect(parseProposal('no fence here')).toBeNull(); expect(parseProposal('```json\n{"none":true}\n```')).toBeNull(); expect(parseProposal('```json\n{not json}\n```')).toBeNull(); expect(parseProposal('```json\n{"args":{}}\n```')).toBeNull(); }); it('defaults args to {} and summary to "" when omitted', () => { expect(parseProposal('```json\n{"tool":"write_file"}\n```')).toEqual({ tool: 'write_file', args: {}, summary: '' }); }); }); describe('runLoopTick — assist mode (L2 proposals)', () => { let tmpDir: string; let db: MindDB; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-loop-a-')); db = new MindDB(path.join(tmpDir, 'test.mind')); }); afterEach(() => { db.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); function assistChat(report: string, fence: string) { return vi.fn(async (prompt: string) => { if (prompt.includes('Return the JSON now')) { return '{"correctness": 8, "procedure": 7, "conciseness": 9, "feedback": "ok"}'; } return `${report}\n\n\`\`\`json\n${fence}\n\`\`\``; }); } const sched = (cfg: Record): LoopSchedule => ({ id: 1, name: 'Assist Loop', job_config: JSON.stringify(cfg), last_run_at: null }); it('report mode never proposes an action', async () => { const chat = vi.fn(async () => 'A plain report, no fence.'); const res = await runLoopTick({ schedule: sched({ prompt: 'sweep' }), mindDb: db, embedder, chat, log: silentLog }); expect(res.proposedAction).toBeUndefined(); }); it('assist mode returns the proposed action and strips the fence from the report + memory', async () => { const chat = assistChat('Three deals went quiet.', '{"tool":"send_email","args":{"to":"x@y.z"},"summary":"nudge them"}'); const res = await runLoopTick({ schedule: sched({ prompt: 'sweep', mode: 'assist' }), mindDb: db, embedder, chat, log: silentLog, }); expect(res.proposedAction).toEqual({ tool: 'send_email', args: { to: 'x@y.z' }, summary: 'nudge them' }); expect(res.summary).toContain('Three deals went quiet'); expect(res.summary).not.toContain('```json'); // The frame written to memory must be the clean report, not the JSON fence. const frames = db.getDatabase().prepare("SELECT content FROM memory_frames WHERE gop_id='loop'").all() as Array<{ content: string }>; expect(frames).toHaveLength(1); expect(frames[0].content).not.toContain('```json'); }); it('assist mode with a {none:true} proposal yields no action', async () => { const chat = assistChat('Nothing actionable today.', '{"none":true}'); const res = await runLoopTick({ schedule: sched({ prompt: 'sweep', mode: 'assist' }), mindDb: db, embedder, chat, log: silentLog }); expect(res.proposedAction).toBeUndefined(); expect(res.summary).toContain('Nothing actionable'); }); });