feat(vibe): reconcile mutable session previews in playback

This commit is contained in:
kami
2026-08-01 23:47:02 +04:00
parent 51ef7c84db
commit 57df1cfe9f
20 changed files with 1311 additions and 312 deletions
+229 -31
View File
@@ -1,21 +1,16 @@
import { AxiosError } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
const { next, start } = vi.hoisted(() => ({ next: vi.fn(), start: vi.fn() }));
vi.mock('./vibeService', () => ({
vibeService: { start, next },
fetchNextBatch: async (count: number, sessionId: string) => {
const tracks: Track[] = [];
for (let index = 0; index < count; index++) {
try { tracks.push((await next(sessionId)).track); } catch { return { tracks, status: 'failed' as const }; }
}
return { tracks, status: 'complete' as const };
},
const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
}));
vi.mock('./vibeService', () => ({ vibeService: { start, next, advancePastUnplayable, event, end } }));
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
import { startVibeSession } from './vibeSession';
import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
@@ -23,34 +18,237 @@ const track = (id: string): Track => ({
play_count: 0, skip_count: 0, dislike_count: 0,
});
describe('startVibeSession', () => {
const item = (track_id: string, committed = false, ordinal = 0) => ({
plan_version_id: 'plan', ordinal, track_id, slot_role: null, candidate_source: 'test',
score: 1, score_breakdown: {}, explanation: [], committed,
});
const response = (planVersion: number, now = item('one', true), preview = [item('two')]) => ({
sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null,
});
describe('durable Vibe session client', () => {
beforeEach(() => {
vi.clearAllMocks();
useVibeStore.getState().reset();
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
usePlaybackStore.setState({
currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary',
});
getTrack.mockImplementation((id: string) => Promise.resolve(track(id)));
});
it('does not replace a working Vibe when the new plan cannot hydrate', async () => {
const old = track('old');
useVibeStore.getState().setActiveSession({ sessionId: 'old-session', seedTrackId: old.id });
usePlaybackStore.getState().setQueue([old]);
usePlaybackStore.getState().playTrack(old);
start.mockResolvedValue({ sessionId: 'new-session', plan: [] });
next.mockRejectedValue(new Error('missing session'));
it('starts by version-serving and hydrating the first durable plan item', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ tracks: [], status: 'failed' });
expect(useVibeStore.getState().activeSessionId).toBe('old-session');
expect(usePlaybackStore.getState().currentTrack?.id).toBe('old');
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
expect(next).toHaveBeenCalledWith('session-a', 1);
expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] });
expect(usePlaybackStore.getState().currentTrack).toEqual(track('one'));
});
it('serializes rapid starts and hydrates only one session', async () => {
const recommended = track('recommended');
start.mockResolvedValue({ sessionId: 'session-a', plan: [] });
next.mockResolvedValue({ track: recommended });
it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next
.mockResolvedValueOnce(response(1, item('one', true), [item('stale')]))
.mockResolvedValueOnce(response(2, item('two', true), [item('three')]));
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
await startVibeSession(track('seed'));
await Promise.all([startVibeSession(track('seed-a')), startVibeSession(track('seed-b'))]);
expect(start).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
await advanceVibe('skipped');
expect(next).toHaveBeenLastCalledWith('session-a', 2);
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
expect(useVibeStore.getState().buffer.map((entry) => entry.id)).toEqual(['three']);
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('stale');
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one', eventId: expect.any(String) }));
});
it('replaces only the future when a keep event replans', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false });
await startVibeSession(track('seed'));
await reportVibeEvent('kept', 'one');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
expect(useVibeStore.getState().planVersion).toBe(2);
});
it('reconciles the canonical replacement returned by an idempotent material-event retry', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
// The first response was lost after it published revision 2. Retrying the
// same client event returns that revision with replanned=false.
event.mockResolvedValue({
...response(2, item('fresh'), [item('fresh'), item('later')]),
replanned: false,
event: {},
idempotent: true,
});
await startVibeSession(track('seed'));
await reportVibeEvent('kept', 'one');
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh'), track('later')] });
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
});
it('cleans up local playback when the durable session is gone', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
}));
await startVibeSession(track('seed'));
await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');
expect(useVibeStore.getState().activeSessionId).toBeNull();
expect(usePlaybackStore.getState()).toMatchObject({ currentTrack: null, queue: [], isPlaying: false });
});
it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('seed'));
const ordinary = track('ordinary');
const playback = usePlaybackStore.getState();
playback.setQueue([ordinary, track('ordinary-next')]);
playback.playTrack(ordinary);
playback.nextWithReason('completed');
expect(usePlaybackStore.getState()).toMatchObject({
queueOwner: 'ordinary', currentTrack: track('ordinary-next'), vibeAdvanceHandler: null,
});
expect(event).not.toHaveBeenCalled();
});
it('serializes material events and ignores an older plan revision', async () => {
start.mockResolvedValue(response(1, item('one'), [item('old')]));
next.mockResolvedValue(response(1, item('one', true), [item('old')]));
await startVibeSession(track('seed'));
let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
event.mockResolvedValueOnce({ ...response(1, item('stale'), [item('stale')]), replanned: true, event: {}, idempotent: false });
const first = reportVibeEvent('kept', 'one');
const second = reportVibeEvent('completed', 'one');
await Promise.resolve();
expect(event).toHaveBeenCalledTimes(1);
resolveFirst({ ...response(2, item('fresh'), [item('fresh')]), replanned: true, event: {}, idempotent: false });
await Promise.all([first, second]);
expect(event).toHaveBeenCalledTimes(2);
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh')] });
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh']);
});
it('retries a failed event with the same idempotency key until it is acknowledged', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
...response(1), event: {}, idempotent: true,
});
await startVibeSession(track('seed'));
await reportVibeEvent('progress', 'one', 30000, 180000);
expect(event).toHaveBeenCalledTimes(2);
expect(event.mock.calls[0][1].eventId).toBe(event.mock.calls[1][1].eventId);
});
it('skips a hidden plan item and starts from the next playable item', async () => {
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
next
.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
advancePastUnplayable.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
getTrack.mockImplementation((id: string) => id === 'hidden'
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
: Promise.resolve(track(id)));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
expect(next).toHaveBeenCalledTimes(1);
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
planVersionId: 'plan', ordinal: 0, trackId: 'hidden', eventId: expect.any(String),
}));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('hidden');
});
it('advances consecutive hidden replacements directly without replaying an older served cursor', async () => {
start.mockResolvedValue(response(1, item('hidden-one'), [item('hidden-two', false, 1)]));
next.mockResolvedValueOnce(response(1, item('hidden-one', true), [item('hidden-two', false, 1)]));
advancePastUnplayable
.mockResolvedValueOnce(response(1, item('hidden-two', true, 1), [item('good', false, 2)]))
.mockResolvedValueOnce(response(1, item('good', true, 2), [item('later', false, 3)]));
getTrack.mockImplementation((id: string) => ['hidden-one', 'hidden-two'].includes(id)
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
: Promise.resolve(track(id)));
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({
status: 'complete', tracks: [track('good'), track('later')],
});
expect(next).toHaveBeenCalledTimes(1);
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
expect(advancePastUnplayable.mock.calls.map(([, , input]) => [input.ordinal, input.trackId]))
.toEqual([[0, 'hidden-one'], [1, 'hidden-two']]);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
});
it('retries an unplayable advancement with its original event id after a lost response', async () => {
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
next.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
advancePastUnplayable
.mockRejectedValueOnce(new Error('response dropped'))
.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
getTrack.mockImplementation((id: string) => id === 'hidden'
? Promise.resolve({ ...track(id), state: 'MISSING' })
: Promise.resolve(track(id)));
await startVibeSession(track('seed'));
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
expect(advancePastUnplayable.mock.calls[0][2].eventId)
.toBe(advancePastUnplayable.mock.calls[1][2].eventId);
});
it('advances a stream-error track through its stored durable cursor without ordinary feedback', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)]));
next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)]));
advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)]));
await startVibeSession(track('seed'));
await advancePastUnplayableVibeTrack('one');
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
planVersionId: 'plan', ordinal: 0, trackId: 'one', eventId: expect.any(String),
}));
expect(event).not.toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 });
});
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
end.mockResolvedValue(response(1));
await startVibeSession(track('seed'));
await endVibeSession();
expect(end).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState().activeSessionId).toBeNull();
expect(usePlaybackStore.getState()).toMatchObject({
queueOwner: 'ordinary', vibeAdvanceHandler: null, currentTrack: null, queue: [], isPlaying: false,
});
});
});