feat(vibe): add durable versioned session API
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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 }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user