276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
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 }),
|
|
advancePastUnplayableVibePlanItem: 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 }),
|
|
replan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
|
} 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('persists a director-selected arc role and explainable sequence score', async () => {
|
|
const { db, director, coordinator } = setup();
|
|
(director.buildPlan as any).mockResolvedValueOnce([{
|
|
trackId: TRACK_ID, generatorId: 'discovery', relevance: 0.7, explanation: [{ predicate: 'near' }],
|
|
plan: {
|
|
slotRole: 'surprise', score: 0.82,
|
|
scoreBreakdown: { relevance: 0.7, transition: 0.9, arcTarget: 0.85 },
|
|
explanation: { arcRole: 'surprise', surprise: { recoveryRole: 'favorite' } },
|
|
},
|
|
}]);
|
|
|
|
await coordinator.start('user-1', {});
|
|
|
|
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
|
items: [expect.objectContaining({
|
|
slot_role: 'surprise', score: 0.82,
|
|
score_breakdown: expect.objectContaining({ transition: 0.9 }),
|
|
explanation: expect.objectContaining({
|
|
paths: [{ predicate: 'near' }],
|
|
planner: expect.objectContaining({ arcRole: 'surprise' }),
|
|
}),
|
|
})],
|
|
}));
|
|
});
|
|
|
|
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.replan).toHaveBeenCalledWith(
|
|
'user-1', SESSION_ID, expect.any(Array), [TRACK_ID], TRACK_ID,
|
|
{ excludedTrackIds: new Set() },
|
|
);
|
|
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('passes the durable unserved callback tail into the retention-aware replan', async () => {
|
|
const { db, director, coordinator } = setup();
|
|
(db.getVibePlan as any).mockResolvedValue({
|
|
...plan(),
|
|
items: [{
|
|
...plan().items[0],
|
|
slot_role: 'favorite',
|
|
explanation: {
|
|
paths: [],
|
|
planner: { arcRole: 'favorite', callback: { id: 'theme:0', phase: 'return' } },
|
|
},
|
|
}],
|
|
});
|
|
|
|
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: 'other-track' });
|
|
|
|
expect(director.replan).toHaveBeenCalledWith(
|
|
'user-1', SESSION_ID,
|
|
[expect.objectContaining({
|
|
trackId: TRACK_ID,
|
|
plan: expect.objectContaining({ slotRole: 'favorite', explanation: expect.objectContaining({ arcRole: 'favorite' }) }),
|
|
})],
|
|
['other-track'], 'other-track', { excludedTrackIds: new Set() },
|
|
);
|
|
});
|
|
|
|
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.replan).toHaveBeenCalledWith(
|
|
'user-1',
|
|
SESSION_ID,
|
|
expect.any(Array),
|
|
[TRACK_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('advances past an unplayable served item using a distinct idempotent event protocol', async () => {
|
|
const { db, coordinator } = setup();
|
|
const planVersionId = '44444444-4444-4444-8444-444444444444';
|
|
const eventId = '55555555-5555-4555-8555-555555555555';
|
|
|
|
const result = await coordinator.advancePastUnplayable('user-1', SESSION_ID, {
|
|
expectedPlanVersion: 1,
|
|
planVersionId,
|
|
ordinal: 0,
|
|
trackId: TRACK_ID,
|
|
eventId,
|
|
});
|
|
|
|
expect(db.advancePastUnplayableVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', {
|
|
expectedPlanVersion: 1, planVersionId, ordinal: 0, trackId: TRACK_ID, eventId,
|
|
});
|
|
expect(result.now).toMatchObject({ 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 }));
|
|
});
|
|
});
|