From 119ee990e872b2553ce079d6eba0b00615df5470 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Sat, 10 Oct 2026 14:28:04 +0100 Subject: [PATCH 1/4] chore(frontend): sync the client with the conversation_ttl_seconds contract --- openapi.json | 3 +++ types/api.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/openapi.json b/openapi.json index cb4995b..a2d26a0 100644 --- a/openapi.json +++ b/openapi.json @@ -6670,6 +6670,9 @@ "conversation_id": { "type": "string" }, + "conversation_ttl_seconds": { + "type": "integer" + }, "continuation": { "type": "object", "properties": { diff --git a/types/api.ts b/types/api.ts index 55d641a..200b88e 100644 --- a/types/api.ts +++ b/types/api.ts @@ -7317,6 +7317,7 @@ export interface operations { total_competencies: number | null; }; conversation_id?: string; + conversation_ttl_seconds?: number; continuation?: { conversation_id: string; competency_code: string; From 720894147f6e55b0538a5d459acedbe30d4e5896 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Sat, 10 Oct 2026 14:32:58 +0100 Subject: [PATCH 2/4] feat(frontend): crossfade a fresh Tavus handle and hand over before its ceiling --- app/composables/useInterviewSession.ts | 63 +++- .../unit/arch/attribution-cursor-arch.spec.ts | 4 +- .../use-interview-session-ceiling.spec.ts | 269 ++++++++++++++++++ ...use-interview-session-continuation.spec.ts | 9 +- 4 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 tests/unit/use-interview-session-ceiling.spec.ts diff --git a/app/composables/useInterviewSession.ts b/app/composables/useInterviewSession.ts index ce97ec4..a640476 100644 --- a/app/composables/useInterviewSession.ts +++ b/app/composables/useInterviewSession.ts @@ -218,6 +218,12 @@ export interface ProviderSession { * which `useProctor` reads at snapshot time. */ dbSessionId: number + /** + * The `players` key and the `notifyPainted` match. Equal to `dbSessionId`, + * except for a replacement conversation on the SAME row (the ceiling handover): + * two live keys must never collide, so that one takes a negative serial. + */ + playerKey: number /** Which interview-session row this handle's transcript, `/end` and `/suspend` belong to now. */ readonly attribution: AttributionCursor /** From the `/start` response. Never surfaced to the UI (D9) — provider-anonymity. */ @@ -356,6 +362,12 @@ const UTTERANCE_DRAIN_CEILING_MS = 3_000 const HANDOVER_BOUND_MS = 10_000 +/** + * How long before a Tavus conversation's ceiling (`conversation_ttl_seconds`) the + * client asks for its replacement (design N11/N17). Retuned after live gate G-B. + */ +const HANDOVER_LEAD_MS = 120_000 + /** DESIGN.md §10 — fade, 200ms, ease-in-out; instant under reduced motion. */ const CROSSFADE_MS = 200 @@ -567,7 +579,7 @@ export function useInterviewSession( const live = activeSession.value if (live) { list.push({ - key: live.dbSessionId, + key: live.playerKey, provider: live.provider, config: live.config, role: 'live', @@ -578,7 +590,7 @@ export function useInterviewSession( const incoming = incomingSession.value if (incoming) { list.push({ - key: incoming.dbSessionId, + key: incoming.playerKey, provider: incoming.provider, config: incoming.config, role: incomingEntering.value ? 'entering' : 'incoming', @@ -597,6 +609,9 @@ export function useInterviewSession( const handoverBoundTimer = createCancelableTimer() const promoteTimer = createCancelableTimer() const connectingCeilingTimer = createCancelableTimer() + /** Counts down the live Tavus conversation's age (N11); survives handovers, re-armed per fresh handle. */ + const ageTimer = createCancelableTimer() + let replacementKeySerial = 0 // ---- Helpers ------------------------------------------------------------- @@ -667,6 +682,7 @@ export function useInterviewSession( /** D1: clears the LIVE slot only — a hidden incoming is untouched by this. */ function clearActiveProvider() { + ageTimer.clear() activeSession.value = null } @@ -1051,9 +1067,9 @@ export function useInterviewSession( * incoming handle — the live handle's own paint only drives its own * opacity, never the handover (F3: `ready` is not a frame; painted is). */ - function notifyPainted(dbSessionId: number) { + function notifyPainted(playerKey: number) { const incoming = incomingSession.value - if (!incoming || incoming.dbSessionId !== dbSessionId) return + if (!incoming || incoming.playerKey !== playerKey) return if (incomingEntering.value) return // idempotent — a defensive re-fire is a no-op incomingEntering.value = true @@ -1083,6 +1099,7 @@ export function useInterviewSession( if (providerState === 'complete' && isLive) { handleProviderComplete(handle) } + // An incoming handle's own `complete` is ignored (D2) — it has not // been asked a question at handover time; the guard makes it inert. }) @@ -1353,6 +1370,20 @@ export function useInterviewSession( return failure.reason === 'timeout' || failure.reason === 'send_failed' } + /** The conversation is about to age out: fetch its replacement now, mid-competency. */ + function armAgeTimer(handle: ProviderSession, ttlSeconds: number | undefined) { + ageTimer.clear() + if (handle.providerName !== 'tavus' || typeof ttlSeconds !== 'number') return + const delay = ttlSeconds * 1000 - HANDOVER_LEAD_MS + if (delay <= 0) return + ageTimer.arm(() => { + if (handle !== activeSession.value || state.value !== 'live') return + if (boundaryInFlight.value || handoverActive.value) return + logHandoverEvent('age-ceiling') + startNextSession() + }, delay) + } + function handleProviderComplete(handle: ProviderSession) { if (isSteerable(handle)) { void assertBoundary('completed') @@ -1447,12 +1478,11 @@ export function useInterviewSession( return } - if (target === 'boundary') { - // The server refused (or never offered) a continuation: this is today's - // path from here on. Leave the old room, then publish the fresh handle. - joined?.provider.stop().catch(() => {}) - transitionTo('connecting') - } + // The server refused (or never offered) a continuation. A fresh handle that + // arrives while a live one exists crossfades, whatever the provider (D7). + const live = activeSession.value + const crossfade = target === 'incoming' || (target === 'boundary' && live !== null) + if (target === 'boundary' && !crossfade) transitionTo('connecting') // D4: end_phrase and final_phrase come from NESTED question_context — NOT top-level const { end_phrase, final_phrase } = response.question_context @@ -1484,6 +1514,8 @@ export function useInterviewSession( provider: withIdempotentStop(createProvider(providerName, isMock())), config: startConfig, dbSessionId, + // A replacement on the SAME row (mid-competency) must not share its key. + playerKey: live?.playerKey === dbSessionId ? -++replacementKeySerial : dbSessionId, attribution: new AttributionCursor(dbSessionId), providerName, ...(response.conversation_id ? { conversationId: response.conversation_id } : {}), @@ -1498,12 +1530,20 @@ export function useInterviewSession( // (absent_phrase) must already have a listener. wireProviderEvents(handle) - if (target === 'incoming') { + if (crossfade) { incomingSession.value = handle + // Tavus cannot arm the bound at `complete` like HeyGen does: the ceiling is + // only known from this response. Armed at publish, unless a HeyGen handover + // already holds it. + if (live !== null && !handoverActive.value) { + handoverActive.value = true + armHandoverBound() + } } else { sessionId.value = handle.attribution.current activeSession.value = handle } + armAgeTimer(handle, response.conversation_ttl_seconds) } catch (err) { isResuming = false @@ -1771,6 +1811,7 @@ export function useInterviewSession( async function teardown() { removeResizeListener() + ageTimer.clear() endHandover() const live = activeSession.value const incoming = incomingSession.value diff --git a/tests/unit/arch/attribution-cursor-arch.spec.ts b/tests/unit/arch/attribution-cursor-arch.spec.ts index 544ad31..2533bde 100644 --- a/tests/unit/arch/attribution-cursor-arch.spec.ts +++ b/tests/unit/arch/attribution-cursor-arch.spec.ts @@ -82,10 +82,10 @@ describe('handle.dbSessionId is the player key only', () => { ).toEqual([]) }) - it('the composable reads it only for player keys and the painted match', () => { + it('the composable reads no handle.dbSessionId: keys and the painted match use playerKey', () => { const code = SOURCES.find((s) => s.file === COMPOSABLE)!.code const reads = [...code.matchAll(/\b(\w+)\.dbSessionId\b/g)].map((m) => m[0]).sort() - expect(reads).toEqual(['incoming.dbSessionId', 'incoming.dbSessionId', 'live.dbSessionId']) + expect(reads).toEqual([]) }) it('ProctorOverlay and the question-timer reset read the cursor-fed sessionId', () => { diff --git a/tests/unit/use-interview-session-ceiling.spec.ts b/tests/unit/use-interview-session-ceiling.spec.ts new file mode 100644 index 0000000..26146a0 --- /dev/null +++ b/tests/unit/use-interview-session-ceiling.spec.ts @@ -0,0 +1,269 @@ +/** + * useInterviewSession — Tavus ceiling handover (tavus-single-session-interview, FE-06). + * + * 06a: a fresh handle that arrives while a live one exists crossfades (whatever the + * provider), and a conversation-age timer (`conversation_ttl_seconds` minus + * `HANDOVER_LEAD_MS`) obtains that fresh handle mid-competency. + * + * Harness mirrors use-interview-session-continuation.spec.ts. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { nextTick } from 'vue' + +const { mockCandidateFetch, mockFlushIntegrityKeepalive, mockCreateProvider } = vi.hoisted(() => ({ + mockCandidateFetch: vi.fn(), + mockFlushIntegrityKeepalive: vi.fn(), + mockCreateProvider: vi.fn(), +})) + +vi.mock('~/app/providers/factory', () => ({ createProvider: mockCreateProvider })) +vi.mock('~/app/utils/candidate-api', () => ({ + candidateFetch: mockCandidateFetch, + flushIntegrityKeepalive: mockFlushIntegrityKeepalive, + CandidateUnauthorizedError: class CandidateUnauthorizedError extends Error {}, +})) +vi.mock('~/app/composables/useCandidateSession', () => ({ + useCandidateSession: () => ({ clear: vi.fn(), read: vi.fn(), store: vi.fn() }), +})) + +// eslint-disable-next-line import/first +import { useInterviewSession } from '~/app/composables/useInterviewSession' + +type EventCallback = (payload: unknown) => void + +function createMockProvider() { + const listeners = new Map() + const emit = (evt: string, payload: unknown) => { + for (const cb of listeners.get(evt) ?? []) cb(payload) + } + // The composable wraps `provider.stop`, so assert on the original. Like the + // real provider, a stop reports `stopped`. + const stopSpy = vi.fn(async () => { + emit('state', 'stopped') + }) + return { + _stop: stopSpy, + on: vi.fn((evt: string, cb: EventCallback) => { + listeners.set(evt, [...(listeners.get(evt) ?? []), cb]) + }), + start: vi.fn(async () => ({ providerSessionId: 'p' })), + stop: stopSpy, + toggleMic: vi.fn(async () => undefined), + setMicMuted: vi.fn(async () => undefined), + nudgeWrapUp: vi.fn(), + sendBoundary: vi.fn(async () => ({ ok: true as const })), + _emit: emit, + } +} + +let providers: ReturnType[] = [] +const queues = new Map() + +function queue(path: string, ...responses: unknown[]) { + queues.set(path, [...(queues.get(path) ?? []), ...responses]) +} + +const A = 42 +const B = 77 +const CONV = 'conv-1' +const TTL = 600 +const LEAD_MS = 120_000 +const AGE_MS = TTL * 1000 - LEAD_MS + +function response(sessionId: number, extra: Record = {}) { + return { + session_id: sessionId, + provider: 'tavus', + provider_token: null, + conversation_url: 'https://tavus.test/room', + audio_only: false, + question_context: { + competency_code: 'PRS', + question_index: 0, + end_phrase: 'Passiamo alla prossima domanda.', + final_phrase: 'Grazie.', + prompt_version: 'v1', + }, + ...extra, + } +} + +const fresh = (sessionId = A, ttl: number | null = TTL) => + response(sessionId, { + conversation_id: CONV, + ...(ttl === null ? {} : { conversation_ttl_seconds: ttl }), + }) + +const END_CONTINUE = { next_action: 'continue', ended_competencies: 1, total_competencies: 3 } + +async function flush() { + for (let i = 0; i < 30; i++) await nextTick() +} + +async function liveSession(first: unknown = fresh()) { + const session = useInterviewSession() + session.acceptConsent() + queue('/candidate/interview/start', first) + session.confirmDevices() + await flush() + providers[0]!._emit('state', 'ready') + await flush() + expect(session.state.value).toBe('live') + return session +} + +const callsTo = (path: string) => mockCandidateFetch.mock.calls.filter((c) => c[0] === path) +const starts = () => callsTo('/candidate/interview/start') +const ends = () => callsTo('/candidate/interview/end') + +/** Paint the incoming handle and let the crossfade finish. */ +async function paintIncoming(session: ReturnType) { + const incoming = session.players.value.find((p) => p.role === 'incoming')! + session.notifyPainted(incoming.key) + await vi.advanceTimersByTimeAsync(300) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCandidateFetch.mockReset() + mockFlushIntegrityKeepalive.mockReset() + queues.clear() + mockCandidateFetch.mockImplementation(async (path: string) => queues.get(path)?.shift()) + vi.useFakeTimers() + providers = [] + mockCreateProvider.mockImplementation(() => { + const p = createMockProvider() + providers.push(p) + return p + }) + vi.stubGlobal('navigateTo', vi.fn()) + vi.stubGlobal( + 'useRuntimeConfig', + vi.fn(() => ({ public: { apiBase: 'https://api.test', interviewProviderMock: 'false' } })) + ) + vi.stubGlobal('window', { + innerWidth: 1280, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('FE-06a crossfade predicate: a fresh handle while a live one exists', () => { + it('a Tavus fresh handle at a boundary crossfades instead of tearing the live one down', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', fresh(B)) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(session.state.value).toBe('live') + expect(providers[0]!._stop).not.toHaveBeenCalled() + expect(session.players.value.map((p) => p.role)).toEqual(['live', 'incoming']) + expect(session.handoverInFlight.value).toBe(true) + + await paintIncoming(session) + + expect(session.players.value).toHaveLength(1) + expect(session.sessionId.value).toBe(B) + expect(session.state.value).toBe('live') + expect(providers[1]!.setMicMuted).toHaveBeenLastCalledWith(false) + }) + + it('arms the 10 s bound when the fresh handle is published', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', fresh(B)) + providers[0]!._emit('state', 'complete') + await flush() + + await vi.advanceTimersByTimeAsync(9_999) + expect(session.state.value).toBe('live') + await vi.advanceTimersByTimeAsync(1) + + expect(session.state.value).toBe('connecting') + expect(session.players.value.map((p) => p.role)).toEqual(['incoming']) + }) +}) + +describe('FE-06a age timer', () => { + it('fires at conversation_ttl_seconds minus HANDOVER_LEAD_MS and resumes the in_corso row', async () => { + const session = await liveSession() + queue('/candidate/interview/start', fresh(A)) + + await vi.advanceTimersByTimeAsync(AGE_MS - 1) + expect(starts()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + await flush() + + expect(starts()).toHaveLength(2) + expect(starts()[1]![1]).toEqual({ method: 'POST' }) + expect(ends()).toHaveLength(0) + expect(session.sessionId.value).toBe(A) + expect(session.players.value.map((p) => p.role)).toEqual(['live', 'incoming']) + const keys = session.players.value.map((p) => p.key) + expect(new Set(keys).size).toBe(2) + expect(session.handoverInFlight.value).toBe(true) + }) + + it('keeps the competency: after the crossfade the new handle is live on the same row and re-arms', async () => { + const session = await liveSession() + queue('/candidate/interview/start', fresh(A), fresh(A)) + + await vi.advanceTimersByTimeAsync(AGE_MS) + await flush() + await paintIncoming(session) + + expect(session.players.value).toHaveLength(1) + expect(session.sessionId.value).toBe(A) + expect(session.players.value[0]!.provider).toBe(providers[1]) + + await vi.advanceTimersByTimeAsync(AGE_MS) + await flush() + expect(starts()).toHaveLength(3) + }) + + it('does not fire while a boundary is in flight', async () => { + const session = await liveSession() + let release!: (v: unknown) => void + queue('/candidate/interview/end', new Promise((r) => (release = r))) + queue('/candidate/interview/start', fresh(B)) + providers[0]!._emit('state', 'complete') + await vi.advanceTimersByTimeAsync(10) + expect(session.handoverInFlight.value).toBe(true) + + await vi.advanceTimersByTimeAsync(AGE_MS) + + expect(starts()).toHaveLength(1) + release(END_CONTINUE) + await flush() + }) + + it.each([ + ['no conversation_ttl_seconds', fresh(A, null)], + ['a ttl not above the lead', fresh(A, 120)], + ['a non-Tavus handle', response(A, { provider: 'heygen', conversation_ttl_seconds: TTL })], + ])('does not fire for %s', async (_label, first) => { + await liveSession(first) + + await vi.advanceTimersByTimeAsync(TTL * 1000 * 2) + + expect(starts()).toHaveLength(1) + }) + + it('is cancelled when the competency leaves live (pause)', async () => { + const session = await liveSession() + session.pause() + + await vi.advanceTimersByTimeAsync(AGE_MS * 2) + + expect(starts()).toHaveLength(1) + }) +}) diff --git a/tests/unit/use-interview-session-continuation.spec.ts b/tests/unit/use-interview-session-continuation.spec.ts index 1af35f7..124e9e7 100644 --- a/tests/unit/use-interview-session-continuation.spec.ts +++ b/tests/unit/use-interview-session-continuation.spec.ts @@ -313,12 +313,13 @@ describe('single-session continuation flow', () => { providers[0]!._emit('state', 'complete') await flush() + // FE-06: a fresh handle arriving while a live one exists crossfades; the + // old conversation is released when the new one has painted, not before. expect(mockCreateProvider).toHaveBeenCalledTimes(2) - expect(providers[0]!._stop).toHaveBeenCalled() expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() - expect(session.state.value).toBe('connecting') - expect(session.sessionId.value).toBe(B) - expect(session.players.value).toHaveLength(1) + expect(session.state.value).toBe('live') + expect(session.players.value.map((p) => p.role)).toEqual(['live', 'incoming']) + expect(session.sessionId.value).toBe(A) }) it('a Tavus handle that never named a conversation keeps today path (no mute, no assertion)', async () => { From 496eadcb9bdcb4f2199c0e9060e92a213599f8ae Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Sat, 10 Oct 2026 14:33:52 +0100 Subject: [PATCH 3/4] feat(frontend): resume a Tavus conversation that ends unannounced --- app/composables/useInterviewSession.ts | 22 +++++ app/providers/tavus.ts | 22 ++++- tests/unit/tavus-provider-ended.spec.ts | 86 +++++++++++++++++++ .../use-interview-session-ceiling.spec.ts | 63 ++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 tests/unit/tavus-provider-ended.spec.ts diff --git a/app/composables/useInterviewSession.ts b/app/composables/useInterviewSession.ts index a640476..e066342 100644 --- a/app/composables/useInterviewSession.ts +++ b/app/composables/useInterviewSession.ts @@ -520,10 +520,14 @@ export function isValidStartResponse(response: unknown): response is StartRespon * `handle.provider.stop()` freely — concurrently, redundantly, defensively — * and the real SDK/network teardown still only happens once. */ +/** Providers the client has asked to stop: their `stopped` is ours, not an unannounced end. */ +const stoppedByClient = new WeakSet() + function withIdempotentStop(provider: InterviewProvider): InterviewProvider { const originalStop = provider.stop.bind(provider) let stopPromise: Promise | null = null provider.stop = () => { + stoppedByClient.add(provider) if (!stopPromise) stopPromise = originalStop() return stopPromise } @@ -1100,6 +1104,9 @@ export function useInterviewSession( handleProviderComplete(handle) } + if (providerState === 'stopped' && isLive && !stoppedByClient.has(handle.provider)) { + handleUnannouncedEnd(handle) + } // An incoming handle's own `complete` is ignored (D2) — it has not // been asked a question at handover time; the guard makes it inert. }) @@ -1370,6 +1377,21 @@ export function useInterviewSession( return failure.reason === 'timeout' || failure.reason === 'send_failed' } + /** + * N17: a Tavus conversation ended without the client asking (the ceiling arrives + * unannounced). While the competency is still `in_corso` that is treated exactly + * like the age timer firing late: `/start` on the same row. Not while a boundary + * or handover is settling (the end is then expected), and never once the client + * itself stopped the handle (`stoppedByClient`), which covers pause, done and + * every deliberate teardown. + */ + function handleUnannouncedEnd(handle: ProviderSession) { + if (handle.providerName !== 'tavus') return + if (state.value !== 'live' || boundaryInFlight.value || handoverActive.value) return + logHandoverEvent('unannounced-end') + startNextSession() + } + /** The conversation is about to age out: fetch its replacement now, mid-competency. */ function armAgeTimer(handle: ProviderSession, ttlSeconds: number | undefined) { ageTimer.clear() diff --git a/app/providers/tavus.ts b/app/providers/tavus.ts index 3a849b7..57229d3 100644 --- a/app/providers/tavus.ts +++ b/app/providers/tavus.ts @@ -76,6 +76,8 @@ export class TavusProvider implements InterviewProvider, SupportsContextSteering private phrases: { endPhrase: string; finalPhrase: string } | null = null private emittedReady = false private steering: Steering | null = null + /** Set by `stop()`: a `left-meeting` after it is ours, not an unannounced end. */ + private stopping = false /** * Avatar utterances already emitted (key -> time seen), oldest first. @@ -178,7 +180,16 @@ export class TavusProvider implements InterviewProvider, SupportsContextSteering this.attachTrack(track) }) - this.call.on('left-meeting', () => this.failSteering('left')) + this.call.on('left-meeting', () => { + this.failSteering('left') + + // The conversation ended without `stop()` (the ceiling, observed: design + // N17). Reported as a stop; the client decides whether that is a problem. + if (!this.stopping) { + this.stopping = true + this.emitState('stopped') + } + }) this.call.on('error', () => this.failSteering('error')) this.call.on('app-message', (event) => { @@ -392,6 +403,10 @@ export class TavusProvider implements InterviewProvider, SupportsContextSteering } async stop(): Promise { + const alreadyReported = this.stopping + + this.stopping = true + if (this.call) { try { await this.call.leave() @@ -408,6 +423,9 @@ export class TavusProvider implements InterviewProvider, SupportsContextSteering this.stream = null this.settleSteering({ ok: false, reason: 'left' }) this.seenAvatar.clear() - this.emitState('stopped') + + if (!alreadyReported) { + this.emitState('stopped') + } } } diff --git a/tests/unit/tavus-provider-ended.spec.ts b/tests/unit/tavus-provider-ended.spec.ts new file mode 100644 index 0000000..e902a40 --- /dev/null +++ b/tests/unit/tavus-provider-ended.spec.ts @@ -0,0 +1,86 @@ +/** + * TavusProvider reports a conversation that ended without the client asking + * (design N17). The observed ceiling sequence, replayed through an injected fake + * Daily: conversation.left -> system.shutdown -> avatar track-stopped -> + * participant-left -> Daily error "Meeting has ended" -> left-meeting. + */ +import { describe, it, expect, vi } from 'vitest' +import { TavusProvider } from '~/app/providers/tavus' + +type Handler = (data: unknown) => void + +function setup() { + const handlers = new Map() + const fire = (event: string, data: unknown = {}) => + (handlers.get(event) ?? []).forEach((cb) => cb(data)) + const frame = { + on: vi.fn((event: string, cb: Handler) => { + handlers.set(event, [...(handlers.get(event) ?? []), cb]) + }), + join: vi.fn().mockResolvedValue(undefined), + // Leaving on purpose makes Daily report `left-meeting` too. + leave: vi.fn(async () => fire('left-meeting')), + destroy: vi.fn().mockResolvedValue(undefined), + sendAppMessage: vi.fn(), + meetingState: vi.fn(() => 'joined-meeting'), + } + const provider = new TavusProvider(vi.fn().mockResolvedValue(frame)) + const states: string[] = [] + const errors: unknown[] = [] + provider.on('state', (s) => states.push(s as string)) + provider.on('error', (e) => errors.push(e)) + const start = () => + provider.start(document.createElement('div'), { + dbSessionId: 1, + conversationUrl: 'https://tavus.daily.co/x', + endPhrase: 'Next.', + finalPhrase: 'Thanks.', + }) + return { provider, frame, states, errors, fire, start } +} + +function replayEndSequence(fire: (event: string, data?: unknown) => void) { + const message = (event_type: string) => + fire('app-message', { data: { message_type: 'conversation', event_type } }) + message('conversation.left') + message('system.shutdown') + fire('track-stopped', { participant: { local: false } }) + fire('participant-left', { participant: { local: false } }) + fire('error', { errorMsg: 'Meeting has ended' }) + fire('left-meeting') +} + +describe('TavusProvider unannounced end', () => { + it('reports exactly one stop for the observed end sequence, and no error', async () => { + const c = setup() + await c.start() + c.states.length = 0 + + replayEndSequence(c.fire) + + expect(c.states).toEqual(['stopped']) + expect(c.errors).toEqual([]) + }) + + it('a deliberate stop() reports its own single stop, not a second one from left-meeting', async () => { + const c = setup() + await c.start() + c.states.length = 0 + + await c.provider.stop() + + expect(c.frame.leave).toHaveBeenCalledOnce() + expect(c.states).toEqual(['stopped']) + }) + + it('stays silent when left-meeting arrives after a stop', async () => { + const c = setup() + await c.start() + await c.provider.stop() + c.states.length = 0 + + c.fire('left-meeting') + + expect(c.states).toEqual([]) + }) +}) diff --git a/tests/unit/use-interview-session-ceiling.spec.ts b/tests/unit/use-interview-session-ceiling.spec.ts index 26146a0..07f9213 100644 --- a/tests/unit/use-interview-session-ceiling.spec.ts +++ b/tests/unit/use-interview-session-ceiling.spec.ts @@ -4,6 +4,8 @@ * 06a: a fresh handle that arrives while a live one exists crossfades (whatever the * provider), and a conversation-age timer (`conversation_ttl_seconds` minus * `HANDOVER_LEAD_MS`) obtains that fresh handle mid-competency. + * 06b: a Tavus conversation that ends unannounced while a competency is `in_corso` + * is resumed through the same path (design N17). * * Harness mirrors use-interview-session-continuation.spec.ts. */ @@ -267,3 +269,64 @@ describe('FE-06a age timer', () => { expect(starts()).toHaveLength(1) }) }) + +describe('FE-06b unannounced end (N17)', () => { + it('a stop the client did not request resumes the in_corso row via /start', async () => { + const session = await liveSession() + queue('/candidate/interview/start', fresh(A)) + + providers[0]!._emit('state', 'stopped') + await flush() + + expect(starts()).toHaveLength(2) + expect(starts()[1]![1]).toEqual({ method: 'POST' }) + expect(ends()).toHaveLength(0) + expect(session.sessionId.value).toBe(A) + expect(session.players.value.map((p) => p.role)).toContain('incoming') + }) + + it('does not resume on a deliberate client-side stop', async () => { + const session = await liveSession() + queue('/candidate/interview/end', { next_action: 'pause' }) + + await session.endQuestion('timeout') + await flush() + + expect(providers[0]!._stop).toHaveBeenCalled() + expect(starts()).toHaveLength(1) + }) + + it('does not resume when the client stops a Tavus handle that names no conversation', async () => { + const session = await liveSession(response(A)) + queue('/candidate/interview/end', { next_action: 'pause' }) + + await session.endQuestion('timeout') + await flush() + + expect(providers[0]!._stop).toHaveBeenCalled() + expect(starts()).toHaveLength(1) + expect(session.state.value).toBe('end_of_question') + }) + + it('does not resume on a pause', async () => { + const session = await liveSession() + + session.pause() + await flush() + + expect(starts()).toHaveLength(1) + }) + + it('does not resume after the final competency', async () => { + const session = await liveSession() + queue('/candidate/interview/end', { next_action: 'done' }) + + providers[0]!._emit('state', 'complete') + await flush() + providers[0]!._emit('state', 'stopped') + await flush() + + expect(session.state.value).toBe('done') + expect(starts()).toHaveLength(1) + }) +}) From 459d905e9bb7d135330b29840b730a5ea2643984 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Sat, 10 Oct 2026 14:45:34 +0100 Subject: [PATCH 4/4] fix(frontend): keep the age timer on the live Tavus handle Arm the conversation-age timer only where a handle becomes active (plain publish or promotion), so a crossfade incoming no longer replaces the live handle's timer. Restore the joined-room stop on the non-crossfade boundary fallback, and document why an unannounced end during a boundary or handover is left to those paths. --- app/composables/useInterviewSession.ts | 31 ++++++-- .../use-interview-session-ceiling.spec.ts | 71 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/app/composables/useInterviewSession.ts b/app/composables/useInterviewSession.ts index e066342..374c775 100644 --- a/app/composables/useInterviewSession.ts +++ b/app/composables/useInterviewSession.ts @@ -228,6 +228,8 @@ export interface ProviderSession { readonly attribution: AttributionCursor /** From the `/start` response. Never surfaced to the UI (D9) — provider-anonymity. */ providerName: ProviderName + /** `conversation_ttl_seconds` of this handle's conversation, when the server named one. */ + ttlSeconds?: number /** * The provider conversation this handle is joined to, when the server named it * (a fresh single-session `/start`). Its presence is the single-session gate: @@ -1060,6 +1062,7 @@ export function useInterviewSession( incoming.provider.setMicMuted(false).catch(() => {}) activeSession.value = incoming sessionId.value = incoming.attribution.current + armAgeTimer(incoming) // The machine stays `live` for the whole handover (D2); this also covers // a LATE promotion reached from the bound-exceeded `connecting` fallback. transitionTo('live') @@ -1384,6 +1387,10 @@ export function useInterviewSession( * or handover is settling (the end is then expected), and never once the client * itself stopped the handle (`stoppedByClient`), which covers pause, done and * every deliberate teardown. + * + * The skip is safe, not a gap: a boundary or handover in flight always ends in a + * fresh handle, a failed steering that re-enters `/start`, or the bound and + * connecting-ceiling timers, so resuming here as well would double-start. */ function handleUnannouncedEnd(handle: ProviderSession) { if (handle.providerName !== 'tavus') return @@ -1392,9 +1399,17 @@ export function useInterviewSession( startNextSession() } - /** The conversation is about to age out: fetch its replacement now, mid-competency. */ - function armAgeTimer(handle: ProviderSession, ttlSeconds: number | undefined) { + /** + * The conversation is about to age out: fetch its replacement now, mid-competency. + * + * The single timer follows the handle that is LIVE, so this runs only where a + * handle becomes `activeSession` (a plain publish, or `promote()`), never when + * a crossfade incoming is merely published: arming then would replace the live + * handle's timer with one for a handle that may never be promoted. + */ + function armAgeTimer(handle: ProviderSession) { ageTimer.clear() + const ttlSeconds = handle.ttlSeconds if (handle.providerName !== 'tavus' || typeof ttlSeconds !== 'number') return const delay = ttlSeconds * 1000 - HANDOVER_LEAD_MS if (delay <= 0) return @@ -1504,7 +1519,12 @@ export function useInterviewSession( // arrives while a live one exists crossfades, whatever the provider (D7). const live = activeSession.value const crossfade = target === 'incoming' || (target === 'boundary' && live !== null) - if (target === 'boundary' && !crossfade) transitionTo('connecting') + if (target === 'boundary' && !crossfade) { + // Leave the joined room: the live slot was unpublished while /start was in + // flight (a 401 terminal does that without a stop). Idempotent otherwise. + joined?.provider.stop().catch(() => {}) + transitionTo('connecting') + } // D4: end_phrase and final_phrase come from NESTED question_context — NOT top-level const { end_phrase, final_phrase } = response.question_context @@ -1541,6 +1561,9 @@ export function useInterviewSession( attribution: new AttributionCursor(dbSessionId), providerName, ...(response.conversation_id ? { conversationId: response.conversation_id } : {}), + ...(typeof response.conversation_ttl_seconds === 'number' + ? { ttlSeconds: response.conversation_ttl_seconds } + : {}), // `=== true`, not a truthy read: an older API that does not send the // field must resolve to "show the avatar", and `undefined` must never // become "hide it". @@ -1564,8 +1587,8 @@ export function useInterviewSession( } else { sessionId.value = handle.attribution.current activeSession.value = handle + armAgeTimer(handle) } - armAgeTimer(handle, response.conversation_ttl_seconds) } catch (err) { isResuming = false diff --git a/tests/unit/use-interview-session-ceiling.spec.ts b/tests/unit/use-interview-session-ceiling.spec.ts index 07f9213..b7338a6 100644 --- a/tests/unit/use-interview-session-ceiling.spec.ts +++ b/tests/unit/use-interview-session-ceiling.spec.ts @@ -29,6 +29,8 @@ vi.mock('~/app/composables/useCandidateSession', () => ({ useCandidateSession: () => ({ clear: vi.fn(), read: vi.fn(), store: vi.fn() }), })) +// eslint-disable-next-line import/first +import { CandidateUnauthorizedError } from '~/app/utils/candidate-api' // eslint-disable-next-line import/first import { useInterviewSession } from '~/app/composables/useInterviewSession' @@ -232,6 +234,28 @@ describe('FE-06a age timer', () => { expect(starts()).toHaveLength(3) }) + it('follows the handle that becomes live: re-armed at promotion, not at publish', async () => { + const session = await liveSession() + await vi.advanceTimersByTimeAsync(100_000) + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', fresh(B), fresh(B)) + providers[0]!._emit('state', 'complete') + await flush() + expect(session.players.value.map((p) => p.role)).toEqual(['live', 'incoming']) + + await vi.advanceTimersByTimeAsync(5_000) + await paintIncoming(session) + expect(session.players.value).toHaveLength(1) + expect(starts()).toHaveLength(2) + + // The promoted handle's own clock started at promotion, 5 s after publish. + await vi.advanceTimersByTimeAsync(AGE_MS - 1_000) + expect(starts()).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1_000) + await flush() + expect(starts()).toHaveLength(3) + }) + it('does not fire while a boundary is in flight', async () => { const session = await liveSession() let release!: (v: unknown) => void @@ -330,3 +354,50 @@ describe('FE-06b unannounced end (N17)', () => { expect(starts()).toHaveLength(1) }) }) + +describe('unannounced end while a boundary is in flight (N17 skip, R3-003)', () => { + it('is left to the boundary, which re-establishes the handle: no second /start', async () => { + const session = await liveSession() + let release!: (v: unknown) => void + queue('/candidate/interview/end', new Promise((r) => (release = r))) + queue('/candidate/interview/start', fresh(B)) + providers[0]!._emit('state', 'complete') + await vi.advanceTimersByTimeAsync(10) + + providers[0]!._emit('state', 'stopped') + await flush() + expect(starts()).toHaveLength(1) + + release(END_CONTINUE) + await flush() + + expect(starts()).toHaveLength(2) + expect(session.players.value.map((p) => p.role)).toContain('incoming') + }) +}) + +describe('boundary fallback leaves the joined room (R3-004)', () => { + it('stops the joined handle when it was unpublished while /start was in flight', async () => { + const session = await liveSession() + let release!: (v: unknown) => void + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', new Promise((r) => (release = r))) + providers[0]!._emit('state', 'complete') + await flush() + expect(starts()).toHaveLength(2) + + // A 401 elsewhere ends the session and unpublishes the live slot without a stop. + mockCandidateFetch.mockImplementationOnce(async () => { + throw new CandidateUnauthorizedError() + }) + providers[0]!._emit('transcript', { role: 'user', text: 'hi', ts: 1 }) + await flush() + expect(session.state.value).toBe('terminal') + expect(providers[0]!._stop).not.toHaveBeenCalled() + + release(fresh(B)) + await flush() + + expect(providers[0]!._stop).toHaveBeenCalled() + }) +})