Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 94 additions & 8 deletions app/composables/useInterviewSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,18 @@ 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. */
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:
Expand Down Expand Up @@ -356,6 +364,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

Expand Down Expand Up @@ -508,10 +522,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<InterviewProvider>()

function withIdempotentStop(provider: InterviewProvider): InterviewProvider {
const originalStop = provider.stop.bind(provider)
let stopPromise: Promise<void> | null = null
provider.stop = () => {
stoppedByClient.add(provider)
if (!stopPromise) stopPromise = originalStop()
return stopPromise
}
Expand Down Expand Up @@ -567,7 +585,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',
Expand All @@ -578,7 +596,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',
Expand All @@ -597,6 +615,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 -------------------------------------------------------------

Expand Down Expand Up @@ -667,6 +688,7 @@ export function useInterviewSession(

/** D1: clears the LIVE slot only — a hidden incoming is untouched by this. */
function clearActiveProvider() {
ageTimer.clear()
activeSession.value = null
}

Expand Down Expand Up @@ -1040,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')
Expand All @@ -1051,9 +1074,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
Expand Down Expand Up @@ -1083,6 +1106,10 @@ export function useInterviewSession(
if (providerState === 'complete' && isLive) {
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.
})
Expand Down Expand Up @@ -1353,6 +1380,47 @@ 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.
*
* 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
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.
*
* 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
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')
Expand Down Expand Up @@ -1447,9 +1515,13 @@ 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.
// 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) {
// 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')
}
Expand Down Expand Up @@ -1484,9 +1556,14 @@ 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 } : {}),
...(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".
Expand All @@ -1498,11 +1575,19 @@ 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)
}
} catch (err) {
isResuming = false
Expand Down Expand Up @@ -1771,6 +1856,7 @@ export function useInterviewSession(

async function teardown() {
removeResizeListener()
ageTimer.clear()
endHandover()
const live = activeSession.value
const incoming = incomingSession.value
Expand Down
22 changes: 20 additions & 2 deletions app/providers/tavus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -392,6 +403,10 @@ export class TavusProvider implements InterviewProvider, SupportsContextSteering
}

async stop(): Promise<void> {
const alreadyReported = this.stopping

this.stopping = true

if (this.call) {
try {
await this.call.leave()
Expand All @@ -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')
}
}
}
3 changes: 3 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6670,6 +6670,9 @@
"conversation_id": {
"type": "string"
},
"conversation_ttl_seconds": {
"type": "integer"
},
"continuation": {
"type": "object",
"properties": {
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/arch/attribution-cursor-arch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/tavus-provider-ended.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, Handler[]>()
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([])
})
})
Loading
Loading