You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
151 lines
4.9 KiB
151 lines
4.9 KiB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { ServerEvent } from '../../core/protocol.js';
|
|
import { deriveAvatarState, type AvatarStateSource } from './avatar-state.js';
|
|
|
|
/** Minimal stand-in for RealtimeClient/ResilientRealtimeClient — only .on() is used. */
|
|
class FakeSource implements AvatarStateSource {
|
|
private handler: ((event: ServerEvent) => void) | null = null;
|
|
|
|
on(handler: (event: ServerEvent) => void): () => void {
|
|
this.handler = handler;
|
|
return () => {
|
|
this.handler = null;
|
|
};
|
|
}
|
|
|
|
emit(event: ServerEvent): void {
|
|
this.handler?.(event);
|
|
}
|
|
}
|
|
|
|
describe('deriveAvatarState', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('subscribe() emits the current state immediately', () => {
|
|
const store = deriveAvatarState(new FakeSource());
|
|
const run = vi.fn();
|
|
store.subscribe(run);
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
expect(run).toHaveBeenCalledWith('idleActive');
|
|
});
|
|
|
|
it('walks listening -> thinking -> talking -> idleActive across a full voice turn', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source);
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
store.setListening(true);
|
|
store.setListening(false);
|
|
source.emit({ type: 'output_audio.chunk', audio: '' });
|
|
source.emit({ type: 'output_audio.done' });
|
|
|
|
expect(seen).toEqual(['idleActive', 'listening', 'thinking', 'talking', 'idleActive']);
|
|
});
|
|
|
|
it('notifyResponseRequested() drives thinking for the typed-text path', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source);
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
store.notifyResponseRequested();
|
|
source.emit({ type: 'transcript.delta', role: 'assistant', text: 'hi' });
|
|
source.emit({ type: 'transcript.done', role: 'assistant' });
|
|
|
|
expect(seen).toEqual(['idleActive', 'thinking', 'talking', 'idleActive']);
|
|
});
|
|
|
|
it('ignores user transcript events (no signal either way)', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source);
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
source.emit({ type: 'transcript.delta', role: 'user', text: 'hello' });
|
|
|
|
expect(seen).toEqual(['idleActive']);
|
|
});
|
|
|
|
it('session.ready and session.closed reset to idleActive from any state', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source);
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
source.emit({ type: 'output_audio.chunk', audio: '' }); // -> talking
|
|
source.emit({ type: 'session.closed', reason: 'client_requested' });
|
|
|
|
expect(seen).toEqual(['idleActive', 'talking', 'idleActive']);
|
|
});
|
|
|
|
it('response.done is a fallback that stops talking even without output_audio.done', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source);
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
source.emit({ type: 'output_audio.chunk', audio: '' });
|
|
source.emit({ type: 'response.done' });
|
|
|
|
expect(seen).toEqual(['idleActive', 'talking', 'idleActive']);
|
|
});
|
|
|
|
it('destroy() stops reacting to further events', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source);
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
store.destroy();
|
|
source.emit({ type: 'output_audio.chunk', audio: '' });
|
|
|
|
expect(seen).toEqual(['idleActive']);
|
|
});
|
|
|
|
it('settles idleActive into idlePassive after the configured timeout with no other signal', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source, { idlePassiveTimeoutMs: 5_000 });
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
vi.advanceTimersByTime(5_000);
|
|
|
|
expect(seen).toEqual(['idleActive', 'idlePassive']);
|
|
});
|
|
|
|
it('does not fire idle_timeout while something else is happening', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source, { idlePassiveTimeoutMs: 5_000 });
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
vi.advanceTimersByTime(4_000);
|
|
store.setListening(true); // -> listening, cancels/re-arms the idle timer
|
|
vi.advanceTimersByTime(4_000); // < 5s since re-arm — still listening
|
|
store.setListening(false); // -> thinking
|
|
|
|
expect(seen).toEqual(['idleActive', 'listening', 'thinking']);
|
|
});
|
|
|
|
it('re-arms the idle timer once back in idleActive, and destroy() cancels it', () => {
|
|
const source = new FakeSource();
|
|
const store = deriveAvatarState(source, { idlePassiveTimeoutMs: 5_000 });
|
|
const seen: string[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
|
|
source.emit({ type: 'output_audio.chunk', audio: '' }); // -> talking
|
|
source.emit({ type: 'output_audio.done' }); // -> idleActive, re-arms timer
|
|
|
|
store.destroy();
|
|
vi.advanceTimersByTime(5_000);
|
|
|
|
expect(seen).toEqual(['idleActive', 'talking', 'idleActive']);
|
|
});
|
|
});
|
|
|