import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Job, Repository } from '../../db/repository.js'; import type { AppConfig } from '../../config.js'; vi.mock('./load-inputs.js', () => ({ loadReflectionInputs: vi.fn() })); vi.mock('./reflection-prompt.js', () => ({ buildSystemPrompt: vi.fn().mockReturnValue('SYSTEM'), buildUserPrompt: vi.fn().mockReturnValue('USER'), })); vi.mock('./llm-client.js', () => ({ callReflectionLlm: vi.fn() })); vi.mock('./applier.js', () => ({ applyReflectionUnlocked: vi.fn() })); vi.mock('./snapshot.js', () => ({ writeSnapshot: vi.fn() })); vi.mock('./user-lock.js', () => ({ withUserLock: vi.fn(async (_dir: string, _user: string, fn: () => Promise) => fn()), })); vi.mock('../piece-catalog.js', () => ({ PieceCatalog: vi.fn() })); import { loadReflectionInputs } from './load-inputs.js'; import { callReflectionLlm } from './llm-client.js'; import { applyReflectionUnlocked } from './applier.js'; import { writeSnapshot } from './snapshot.js'; import { withUserLock } from './user-lock.js'; import { runReflectionJob } from './reflection-runner.js'; const PAYLOAD = { originalJobId: 'orig-1', userId: 'user-1', pieceName: 'chat', outcome: 'succeeded' as const, }; function makeJob(payload: unknown = PAYLOAD): Job { return { id: 'refl-1', payload: payload === null ? null : JSON.stringify(payload), } as unknown as Job; } function makeDeps() { const repo = { recordReflectionMetric: vi.fn(), } as unknown as Repository; return { repo, config: { reflection: {}, userFolderRoot: 'data/users' } as unknown as AppConfig, llmEndpoint: 'http://localhost:1', llmApiKey: 'sk-reflection-test', llmModel: 'test-model', }; } const INPUT = { memoryEntries: [], pieceYaml: 'yaml' }; const LLM_RESULT = { parsed: { reasoning: 'because' }, tokensIn: 100, tokensOut: 50, durationMs: 5, }; function applierResult(overrides: Record = {}) { return { outcome: 'applied', memoryDecisions: [ { accepted: true, change: { op: 'add', name: 'fact-1', description: 'd', type: 'project', body: 'b' } }, { accepted: false, code: 'too_vague', change: { op: 'add', name: 'fact-2', description: 'd', type: 'project', body: 'b' } }, ], pieceApplied: false, ...overrides, }; } beforeEach(() => { vi.clearAllMocks(); vi.mocked(loadReflectionInputs).mockResolvedValue(INPUT as never); vi.mocked(callReflectionLlm).mockResolvedValue(LLM_RESULT as never); vi.mocked(applyReflectionUnlocked).mockResolvedValue(applierResult() as never); vi.mocked(writeSnapshot).mockResolvedValue({ dir: '/snap/dir' } as never); }); describe('runReflectionJob', () => { it('returns failed for a job without payload and records no metric', async () => { const deps = makeDeps(); const outcome = await runReflectionJob(deps, makeJob(null)); expect(outcome).toBe('failed'); expect(deps.repo.recordReflectionMetric).not.toHaveBeenCalled(); expect(loadReflectionInputs).not.toHaveBeenCalled(); }); it('records a failed metric when input loading throws', async () => { vi.mocked(loadReflectionInputs).mockRejectedValue(new Error('db gone')); const deps = makeDeps(); const outcome = await runReflectionJob(deps, makeJob()); expect(outcome).toBe('failed'); expect(deps.repo.recordReflectionMetric).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'failed', tokens_in: 0, tokens_out: 0 }), ); expect(callReflectionLlm).not.toHaveBeenCalled(); }); it('records a failed metric when the LLM call throws', async () => { vi.mocked(callReflectionLlm).mockRejectedValue(new Error('timeout')); const deps = makeDeps(); const outcome = await runReflectionJob(deps, makeJob()); expect(outcome).toBe('failed'); expect(deps.repo.recordReflectionMetric).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'failed', tokens_in: 0 }), ); expect(applyReflectionUnlocked).not.toHaveBeenCalled(); }); it('applies, snapshots inside the user lock and records an applied metric', async () => { const deps = makeDeps(); const outcome = await runReflectionJob(deps, makeJob()); expect(outcome).toBe('applied'); expect(withUserLock).toHaveBeenCalledWith('data/users', 'user-1', expect.any(Function)); expect(writeSnapshot).toHaveBeenCalledTimes(1); const snapMeta = vi.mocked(writeSnapshot).mock.calls[0]?.[3] as Record; expect(snapMeta).toMatchObject({ originalJobId: 'orig-1', userId: 'user-1', pieceName: 'chat', outcome: 'applied', memoryChanges: 1, rejections: [{ code: 'too_vague', name: 'fact-2' }], }); expect(deps.repo.recordReflectionMetric).toHaveBeenCalledWith( expect.objectContaining({ reflection_job_id: 'refl-1', original_job_id: 'orig-1', user_id: 'user-1', piece_name: 'chat', outcome: 'applied', memory_changes: 1, piece_edited: 0, tokens_in: 100, tokens_out: 50, }), ); }); it('propagates an abstained outcome from the applier', async () => { vi.mocked(applyReflectionUnlocked).mockResolvedValue( applierResult({ outcome: 'abstained', memoryDecisions: [] }) as never, ); const deps = makeDeps(); const outcome = await runReflectionJob(deps, makeJob()); expect(outcome).toBe('abstained'); expect(deps.repo.recordReflectionMetric).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'abstained', memory_changes: 0 }), ); }); it('records a failed metric with LLM tokens when apply throws inside the lock', async () => { vi.mocked(applyReflectionUnlocked).mockRejectedValue(new Error('lock contention')); const deps = makeDeps(); const outcome = await runReflectionJob(deps, makeJob()); expect(outcome).toBe('failed'); expect(deps.repo.recordReflectionMetric).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'failed', tokens_in: 100, tokens_out: 50 }), ); }); it('treats a snapshot failure as non-fatal', async () => { vi.mocked(writeSnapshot).mockRejectedValue(new Error('disk full')); const deps = makeDeps(); const outcome = await runReflectionJob(deps, makeJob()); expect(outcome).toBe('applied'); expect(deps.repo.recordReflectionMetric).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'applied' }), ); }); }); describe('LLM credential propagation', () => { it('passes the worker apiKey into the reflection LLM config (401 regression)', async () => { vi.mocked(callReflectionLlm).mockResolvedValue(LLM_RESULT as never); const deps = makeDeps(); await runReflectionJob(deps, makeJob()); expect(callReflectionLlm).toHaveBeenCalled(); const cfg = vi.mocked(callReflectionLlm).mock.calls[0]![0] as { apiKey?: string }; expect(cfg.apiKey).toBe('sk-reflection-test'); }); });