feat(vibe): adapt sessions to context and exploration
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Typecheck / typecheck (backend) (pull_request) Has been cancelled
Typecheck / typecheck (workers) (pull_request) Has been cancelled

This commit is contained in:
kami
2026-08-02 02:08:35 +04:00
parent fe13798c99
commit 61a1373ca9
14 changed files with 782 additions and 16 deletions
+145 -1
View File
@@ -19,6 +19,46 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType
describe('DbService v2 methods', () => {
describe('durable Vibe sessions', () => {
it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
type: 'completed', occurred_at: new Date(), client_event_id: null,
position_ms: null, duration_ms: null, payload: {},
} as any;
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
.mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
.mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
.mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
.mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
.mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
.mockResolvedValueOnce({ rows: [] }) // no audio targets
.mockResolvedValueOnce({ rows: [{
exploration_coefficient: 0.36, discovery_radius: 0.434,
goals: { type: 'familiar', target: 1, progress: 1 },
}] })
.mockResolvedValueOnce({ rows: [] }) // session_state projection
.mockResolvedValueOnce({ rows: [] }) // COMMIT
.mockResolvedValueOnce({ rows: [] }) // BEGIN retry
.mockResolvedValueOnce({ rows: [] }) // profile upsert retry
.mockResolvedValueOnce({ rows: [] }) // marker conflict
.mockResolvedValueOnce({ rows: [] }); // COMMIT retry
await service.projectVibeSessionFeedback(event);
await service.projectVibeSessionFeedback(event);
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles');
expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections');
expect(clientQuery.mock.calls[3][0]).toContain('EXISTS (SELECT 1 FROM play_history');
expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence');
expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient');
expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END');
expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1);
});
it('creates, reads, and ends sessions scoped to their user', async () => {
const { service, poolQuery, clientQuery } = makeTransactionalService();
const session = {
@@ -44,12 +84,59 @@ describe('DbService v2 methods', () => {
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
expect(clientQuery.mock.calls[3][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']);
expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
'user-1', null, expect.any(String), 'v2.1',
JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
]));
expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual(expect.objectContaining({
activity: 'focus',
}));
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
});
it('normalizes context at the persistence boundary for direct callers', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-02T19:00:00.000Z'));
const { service, clientQuery } = makeTransactionalService();
const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [] }) // active-session lock
.mockResolvedValueOnce({ rows: [session] }) // insert
.mockResolvedValueOnce({ rows: [] }); // COMMIT
try {
await service.createVibeSession({
userId: 'user-1',
policyVersion: 'v2.1',
context: {
timeZone: 'UTC',
activity: 'walking',
device: 'phone',
exactCoordinates: '53.1959,50.1002',
browserTelemetry: { batteryPercent: 4, ipAddress: '192.0.2.1' },
localHour: 3,
weekday: 1,
},
});
const insertParameters = clientQuery.mock.calls[3][1];
expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
expect(JSON.parse(insertParameters[2])).toEqual({
timeZone: 'UTC', localHour: 19, weekday: 0, dayKind: 'weekend',
activity: 'walking', device: 'phone',
});
expect(insertParameters.slice(3)).toEqual([
'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
]);
} finally {
vi.useRealTimers();
}
});
it('replaces an owned active session and writes its terminal event before starting another', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
@@ -102,6 +189,63 @@ describe('DbService v2 methods', () => {
);
});
it('sanitizes and projects an inserted context change atomically with its ledger event', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null,
payload: { context: { activity: 'walking', localHour: 12 } },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [event] }) // insert
.mockResolvedValueOnce({ rows: [] }) // session context projection
.mockResolvedValueOnce({ rows: [] }) // legacy session-state projection
.mockResolvedValueOnce({ rows: [] }) // last event timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', type: 'context_changed',
payload: {
context: { activity: 'walking', exactCoordinates: '53.2,50.1' },
rawBrowserTelemetry: { battery: 4 },
},
});
const values = clientQuery.mock.calls[2][1];
const storedPayload = JSON.parse(values[8]);
expect(storedPayload).toEqual({ context: expect.objectContaining({ activity: 'walking' }) });
expect(storedPayload.context).not.toHaveProperty('exactCoordinates');
expect(storedPayload).not.toHaveProperty('rawBrowserTelemetry');
expect(clientQuery.mock.calls[3][0]).toContain('SET context = $3::jsonb');
expect(clientQuery.mock.calls[4][0]).toContain("jsonb_build_object('context'");
});
it('does not apply a retry body to an existing context-change event', async () => {
const { service, clientQuery } = makeTransactionalService();
const canonicalEvent = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null,
payload: { context: { activity: 'focus', localHour: 12 } },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'context_changed',
payload: { context: { activity: 'workout', exactCoordinates: '53.2,50.1' } },
});
expect(result).toEqual({ event: canonicalEvent, inserted: false });
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
expect.stringContaining('SET context = $3::jsonb'),
);
});
it('projects material feedback once with the durable event transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {