import { describe, expect, it, vi } from 'vitest'; import { DbService } from './db.service.js'; import { DEFAULT_VIBE_POLICY_VERSION, VibeSessionCoordinator, VibeSessionLifecycleError, VibePlanNotFoundError, } from './vibe-session-coordinator.service.js'; const SESSION_ID = '11111111-1111-4111-8111-111111111111'; const TRACK_ID = '22222222-2222-4222-8222-222222222222'; const EVENT_ID = '33333333-3333-4333-8333-333333333333'; function session(status: 'active' | 'ended' = 'active') { return { id: SESSION_ID, user_id: 'user-1', status, seed_track_id: null, context: { activity: 'focus' }, policy_version: DEFAULT_VIBE_POLICY_VERSION, started_at: new Date('2026-01-01T00:00:00.000Z'), last_event_at: new Date('2026-01-01T00:00:00.000Z'), ended_at: status === 'ended' ? new Date() : null, } as any; } function plan() { return { id: 'plan-1', session_id: SESSION_ID, version: 1, reason: 'session_started', state_snapshot: { energy: 0.5 }, objective_snapshot: {}, created_at: new Date(), items: [{ plan_version_id: 'plan-1', ordinal: 0, track_id: TRACK_ID, slot_role: 'next', candidate_source: 'comfort', score: 0.8, score_breakdown: { relevance: 0.8 }, explanation: [], committed: false, }], } as any; } function setup() { const db = { createVibeSession: vi.fn().mockResolvedValue(session()), createSessionState: vi.fn().mockResolvedValue(SESSION_ID), recordVibeEvent: vi.fn().mockResolvedValue({ event: { id: 'event-1' }, inserted: true }), persistVibePlan: vi.fn().mockResolvedValue(plan()), publishVibePlan: vi.fn().mockImplementation((input: { version?: number; reason: string }) => Promise.resolve({ ...plan(), version: input.version ?? 2, reason: input.reason, })), getVibeSession: vi.fn().mockResolvedValue(session()), getVibePlan: vi.fn().mockResolvedValue(plan()), endVibeSession: vi.fn().mockResolvedValue(session('ended')), endVibeSessionWithEvent: vi.fn().mockResolvedValue({ session: session('ended'), ended: true }), resumeVibeSession: vi.fn().mockResolvedValue({ session: session(), resumed: true }), serveNextVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }), persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }), getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null), } as unknown as DbService; const director = { buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]), buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }), } as any; return { db, director, coordinator: new VibeSessionCoordinator(db, director) }; } describe('VibeSessionCoordinator', () => { it('creates an authoritative session, shadow state, initial plan revision, and ledger events', async () => { const { db, director, coordinator } = setup(); const response = await coordinator.start('user-1', { context: { activity: 'focus' }, intent: 'deep-work', }); expect(response).toMatchObject({ sessionId: SESSION_ID, planVersion: 1, now: { track_id: TRACK_ID } }); expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1', policyVersion: DEFAULT_VIBE_POLICY_VERSION, })); expect(db.createSessionState).toHaveBeenCalledWith('user-1', 'focus', expect.any(Object), SESSION_ID); expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, undefined); expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION_ID, version: 1, reason: 'session_started', items: [expect.objectContaining({ track_id: TRACK_ID, committed: false })], })); expect((db.recordVibeEvent as any).mock.calls.map(([input]: any[]) => input.type)) .toEqual(['session_started']); }); it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => { const { db, coordinator } = setup(); (db.recordVibeEvent as any).mockResolvedValueOnce({ event: { id: 'event-1', client_event_id: EVENT_ID, type: 'skipped' }, inserted: false, }); (db.getVibePlanForFeedbackEvent as any).mockResolvedValueOnce({ ...plan(), version: 2 }); const response = await coordinator.appendEvent('user-1', SESSION_ID, { eventId: EVENT_ID, type: 'skipped', trackId: TRACK_ID, }); expect(response).toMatchObject({ idempotent: true, planVersion: 2, replanned: false, replanReason: null }); expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ clientEventId: EVENT_ID })); expect(db.persistNextVibePlan).not.toHaveBeenCalled(); }); it('recovers a material feedback replan when its first persistence attempt failed', async () => { const { db, coordinator } = setup(); (db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure')); await expect(coordinator.appendEvent('user-1', SESSION_ID, { eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID, })).rejects.toThrow('temporary database failure'); (db.recordVibeEvent as any).mockResolvedValueOnce({ event: { id: 'event-1', client_event_id: EVENT_ID, type: 'completed' }, inserted: false, }); const recovered = await coordinator.appendEvent('user-1', SESSION_ID, { eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID, }); expect(db.publishVibePlan).toHaveBeenCalledTimes(2); expect(recovered).toMatchObject({ idempotent: true, replanned: true, planVersion: 2 }); }); it('persists a replacement revision for material feedback and returns its preview', async () => { const { db, director, coordinator } = setup(); const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID }); expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, TRACK_ID); expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })], })); expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 }); }); it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => { const { db, director, coordinator } = setup(); const seedTrackId = '44444444-4444-4444-8444-444444444444'; (db.getVibeSession as any).mockResolvedValue({ ...session(), seed_track_id: seedTrackId }); await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID }); expect(director.buildPlan).toHaveBeenCalledWith( 'user-1', SESSION_ID, TRACK_ID, { excludedTrackIds: new Set([seedTrackId]) }, ); }); it('resumes only the caller-owned session and serves a plan item through the durable API', async () => { const { db, coordinator } = setup(); const resumed = await coordinator.start('user-1', { resumeSessionId: SESSION_ID }); const served = await coordinator.serveNext('user-1', SESSION_ID); expect(db.resumeVibeSession).toHaveBeenCalledWith(SESSION_ID, 'user-1'); expect(resumed.sessionId).toBe(SESSION_ID); expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1'); expect(served.now).toMatchObject({ track_id: TRACK_ID }); }); it('returns a lifecycle conflict when the ledger rejects a new terminal-session event', async () => { const { db, coordinator } = setup(); (db.recordVibeEvent as any).mockRejectedValueOnce(new Error('Cannot record a new event for ended Vibe session')); await expect(coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed' })) .rejects.toBeInstanceOf(VibeSessionLifecycleError); }); it('distinguishes a missing requested revision from an empty latest plan', async () => { const { db, coordinator } = setup(); (db.getVibePlan as any).mockResolvedValueOnce(null); await expect(coordinator.getPlan('user-1', SESSION_ID, 99)).rejects.toBeInstanceOf(VibePlanNotFoundError); }); it('returns 404-worthy failure when a session has no latest plan', async () => { const { db, coordinator } = setup(); (db.getVibePlan as any).mockResolvedValueOnce(null); await expect(coordinator.getPlan('user-1', SESSION_ID)).rejects.toBeInstanceOf(VibePlanNotFoundError); }); it('does not commit a stale version-aware next request and returns the current preview', async () => { const { db, coordinator } = setup(); (db.serveNextVibePlanItem as any).mockResolvedValueOnce({ item: null, stale: true }); const result = await coordinator.serveNext('user-1', SESSION_ID, 1); expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', 1); expect(result).toMatchObject({ planVersion: 1, now: { track_id: TRACK_ID } }); }); it('ends an active session once and preserves its latest persisted plan', async () => { const { db, coordinator } = setup(); const response = await coordinator.end('user-1', SESSION_ID); expect(db.endVibeSessionWithEvent).toHaveBeenCalledWith(SESSION_ID, 'user-1'); expect(response.session.status).toBe('ended'); expect(response.planVersion).toBe(1); }); it('does not publish an initial plan when another start replaced the session while planning', async () => { const { db, coordinator } = setup(); (db.publishVibePlan as any).mockRejectedValueOnce(new Error('Cannot publish a plan for replaced Vibe session')); await expect(coordinator.start('user-1', {})).rejects.toBeInstanceOf(VibeSessionLifecycleError); expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ version: 1 })); }); });