diff --git a/app/composables/useInterviewSession.ts b/app/composables/useInterviewSession.ts index e8edb51..ce97ec4 100644 --- a/app/composables/useInterviewSession.ts +++ b/app/composables/useInterviewSession.ts @@ -101,7 +101,16 @@ import { ref, shallowRef, computed, type ComputedRef } from 'vue' import { createProvider } from '~/app/providers/factory' import { effectiveViewportWidth } from '~/app/utils/browser-gate' -import type { InterviewProvider, ProviderName, StartConfig } from '~/app/types/interview-provider' +import { + canSteerContext, + type InterviewProvider, + type ProviderName, + type StartConfig, + type SteeringFailure, + type SupportsContextSteering, +} from '~/app/types/interview-provider' +import { createBoundaryTicket } from '~/app/utils/advance-interaction' +import { asCompetencyCode, type CompetencyCode } from '~/app/utils/competency-codes' import type { operations } from '~~/types/api' import type { IntegrityEventInternal } from '~/app/utils/proctor-config' @@ -213,6 +222,12 @@ export interface ProviderSession { readonly attribution: AttributionCursor /** From the `/start` response. Never surfaced to the UI (D9) — provider-anonymity. */ providerName: ProviderName + /** + * 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: + * without it the handle takes today's one-handle-per-competency path. + */ + conversationId?: string /** * This project's template runs voice-only (`/start` → `audio_only`). * @@ -392,20 +407,46 @@ function providerErrorCode(payload: unknown): string | null { // /start response shape guard // --------------------------------------------------------------------------- +type ParsedContinuation = { conversationId: string; competencyCode: CompetencyCode } + /** - * Explicit shape guard for the `/start` response, checked BEFORE - * `question_context.end_phrase` is ever read. + * `null` when the response carries no continuation (today's path), the parsed + * continuation when it is well formed, `'malformed'` otherwise. * - * Without this, an unguarded destructure throws inside the try block on a - * bad body; `status` is undefined on a plain TypeError, so it used to land in - * the retryable `error` state — retrying forever against a server that will - * answer identically. That is the same defect class the 401 fix addresses: - * retry cannot fix a contract violation, so this is a non-retryable terminal. + * Malformed means: not an object, a missing/empty id, a code that is not a + * competency code (prose cannot pass), or a continuation combined with a + * provider handle (token or URL). A continuation names a conversation the + * browser is already in; a handle would be a second one. */ -function isValidStartResponse(response: unknown): response is StartResponse { +function parseContinuation(r: Record): ParsedContinuation | null | 'malformed' { + const raw = r['continuation'] + if (raw === undefined || raw === null) return null + if (typeof raw !== 'object') return 'malformed' + + const c = raw as Record + if (typeof c['conversation_id'] !== 'string' || c['conversation_id'] === '') return 'malformed' + if (typeof c['competency_code'] !== 'string') return 'malformed' + const competencyCode = asCompetencyCode(c['competency_code']) + if (competencyCode === null) return 'malformed' + + if (r['provider_token'] != null || r['conversation_url'] != null) return 'malformed' + + return { conversationId: c['conversation_id'], competencyCode } +} + +/** Everything `isValidStartResponse` checks except the continuation. */ +function isBaseValidStartResponse(response: unknown): boolean { if (!response || typeof response !== 'object') return false const r = response as Record + const conversationId = r['conversation_id'] + if ( + conversationId !== undefined && + (typeof conversationId !== 'string' || conversationId === '') + ) { + return false + } + // `number` ONLY, as the contract publishes it. Also accepting a string // widened the predicate past the type it returns: `response is StartResponse` // asserts `session_id: number`, so a string body passed a guard whose whole @@ -434,6 +475,22 @@ function isValidStartResponse(response: unknown): response is StartResponse { return true } +/** + * Explicit shape guard for the `/start` response, checked BEFORE + * `question_context.end_phrase` is ever read. + * + * Without this, an unguarded destructure throws inside the try block on a + * bad body; `status` is undefined on a plain TypeError, so it used to land in + * the retryable `error` state — retrying forever against a server that will + * answer identically. That is the same defect class the 401 fix addresses: + * retry cannot fix a contract violation, so this is a non-retryable terminal. + */ +export function isValidStartResponse(response: unknown): response is StartResponse { + if (!isBaseValidStartResponse(response)) return false + + return parseContinuation(response as Record) !== 'malformed' +} + // --------------------------------------------------------------------------- // Idempotent teardown (C2) // --------------------------------------------------------------------------- @@ -493,10 +550,17 @@ export function useInterviewSession( * trips later. `endQuestion()` and `pause()` guard on THIS flag now (B2). */ const handoverActive = ref(false) + /** + * True from the first boundary input until `assertBoundary()` settles: the one + * in-flight guard that makes the three inputs (spoken phrase, 300 s timer, the + * server's turn budget) idempotent. Read by `pause()`/`endQuestion()` like + * `handoverActive`, and surfaced through `handoverInFlight` so Pause is disabled. + */ + const boundaryInFlight = ref(false) const activeProvider = computed(() => activeSession.value?.provider ?? null) const activeConfig = computed(() => activeSession.value?.config ?? null) - const handoverInFlight = computed(() => handoverActive.value) + const handoverInFlight = computed(() => handoverActive.value || boundaryInFlight.value) const players = computed(() => { const list: SessionPlayer[] = [] @@ -1183,7 +1247,118 @@ export function useInterviewSession( transitionTo(directive === 'done' ? 'done' : 'end_of_question') } + /** + * A handle that is joined to a conversation the server named and can take + * context steering: the only kind that is retargeted in place. Everything else + * (HeyGen, the mock, a Tavus handle from a project without single-session) + * keeps today's one-handle-per-competency path. + */ + function isSteerable(handle: ProviderSession): handle is ProviderSession & { + conversationId: string + provider: InterviewProvider & SupportsContextSteering + } { + return ( + handle.providerName === 'tavus' && + handle.conversationId !== undefined && + canSteerContext(handle.provider) + ) + } + + /** + * The one idempotent boundary assertion (design D5). Its three inputs (the + * avatar's closing phrase, the question timer, later the server's turn budget) + * all land here and a second entrant while one is in flight returns at once; if + * two genuinely race to `/end`, the loser's 409 maps to `noop` and acts on nothing. + * + * Order is the point: the uplink closes BEFORE `/end`, so no candidate speech + * exists in the window to be 409-dropped, and it reopens only at the steering + * acknowledgement or its failure (`applyContinuation`), never at the send. + */ + async function assertBoundary(endedReason: 'completed' | EndQuestionReason): Promise { + const handle = activeSession.value + if (!handle || !isSteerable(handle)) return + if (boundaryInFlight.value || handoverActive.value) return + + boundaryInFlight.value = true + // Only `applyContinuation` (reached through the 'continue' branch) may keep + // the mic shut: it reopens at the steering ack or failure. Every other exit, + // a 409, a failed /end (callEnd degrades it to null, which pauses), pause/done + // or a throw, restores it here. + let micHandedOff = false + try { + await handle.provider.setMicMuted(true).catch(() => {}) + + const directive = await callEnd(handle.attribution.current, endedReason) + if (handle !== activeSession.value) return // torn down while /end was in flight + + if (directive === 'noop') return + + if (directive === 'continue') { + micHandedOff = true + await startSession(0, 'boundary') + return + } + + handle.provider.stop().catch(() => {}) + advanceAfterQuestion(directive) + } finally { + if (!micHandedOff) await handle.provider.setMicMuted(false).catch(() => {}) + boundaryInFlight.value = false + } + } + + /** + * The server granted a continuation: the joined conversation now serves + * `dbSessionId`. Moves the cursor (before anything is sent), steers the avatar, + * and reopens the mic at the acknowledgement or at the failure. + * + * A failed steering keeps the cursor on the new row and resends the SAME ticket + * once when the room is still joined. Otherwise, or on a second failure, the + * new competency ends as `timeout` and the next `/start` issues fresh (the + * browser then holds no usable handle, so the server refuses a continuation). + */ + async function applyContinuation( + handle: ProviderSession, + dbSessionId: number, + continuation: ParsedContinuation + ): Promise { + if (handle !== activeSession.value) return + if (!isSteerable(handle) || continuation.conversationId !== handle.conversationId) { + transitionTo('error') + return + } + + const ticket = createBoundaryTicket(continuation.conversationId, continuation.competencyCode) + let steering!: ReturnType + advanceAttribution(dbSessionId, () => { + steering = handle.provider.sendBoundary(ticket) + }) + + let result = await steering + if (result.ok) { + await handle.provider.setMicMuted(false).catch(() => {}) + return + } + + await handle.provider.setMicMuted(false).catch(() => {}) + if (isJoinedFailure(result)) result = await handle.provider.sendBoundary(ticket) + if (result.ok) return + + handle.provider.stop().catch(() => {}) + advanceAfterQuestion(await callEnd(handle.attribution.current, 'timeout')) + } + + /** The failures that leave the room joined, so a resend can still land. */ + function isJoinedFailure(failure: SteeringFailure): boolean { + return failure.reason === 'timeout' || failure.reason === 'send_failed' + } + function handleProviderComplete(handle: ProviderSession) { + if (isSteerable(handle)) { + void assertBoundary('completed') + return + } + const isHeyGen = handle.providerName === 'heygen' if (isHeyGen) { @@ -1214,16 +1389,30 @@ export function useInterviewSession( startSession(0, 'incoming') } - async function startSession(attemptNumber = 0, target: 'active' | 'incoming' = 'active') { + async function startSession( + attemptNumber = 0, + target: 'active' | 'incoming' | 'boundary' = 'active' + ) { if (target === 'active') { transitionTo('connecting') } // target === 'incoming': state stays `live` throughout (D2) — it never // enters `connecting` for the happy handover path. + // target === 'boundary': the browser is joined to a conversation and asks + // the server to retarget it. The state stays `live`; it only leaves it on the + // fresh-handle fallback below or on an error. + + // The assertion that the browser is IN the room: sent from the boundary only, + // while the joined handle is still the live one. Every other entry (first + // connect, retry, pause/resume, re-offer, reload) has no handle to assert. + const joined = target === 'boundary' ? activeSession.value : null try { const response = await candidateFetch('/candidate/interview/start', { method: 'POST', + ...(joined?.conversationId + ? { body: { live_conversation_id: joined.conversationId } } + : {}), }) if (!isValidStartResponse(response)) { @@ -1232,11 +1421,39 @@ export function useInterviewSession( abandonIncomingAttempt() return } + if (isBaseValidStartResponse(response)) { + // Only the continuation is wrong: a contract slip the next /start can + // fix, so the retryable screen, with no cursor move and nothing sent. + transitionTo('error') + return + } terminalReason.value = 'malformed_response' transitionTo('terminal') return } + const continuation = parseContinuation(response as unknown as Record) + if (continuation !== null && continuation !== 'malformed') { + // No handle was issued: the joined one keeps playing. Released before + // the steering is applied because a failed steering re-enters + // `confirmDevices()`, which the latch would otherwise refuse. + isResuming = false + if (target === 'boundary' && joined) { + await applyContinuation(joined, Number(response.session_id), continuation) + } else { + // A continuation to a caller that holds no handle cannot be honoured. + transitionTo('error') + } + 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') + } + // D4: end_phrase and final_phrase come from NESTED question_context — NOT top-level const { end_phrase, final_phrase } = response.question_context const dbSessionId = Number(response.session_id) @@ -1269,6 +1486,7 @@ export function useInterviewSession( dbSessionId, attribution: new AttributionCursor(dbSessionId), providerName, + ...(response.conversation_id ? { conversationId: response.conversation_id } : {}), // `=== 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". @@ -1419,7 +1637,7 @@ export function useInterviewSession( */ function pause() { if (state.value !== 'live') return - if (handoverActive.value) return + if (handoverActive.value || boundaryInFlight.value) return const handle = activeSession.value @@ -1507,6 +1725,11 @@ export function useInterviewSession( const handle = activeSession.value if (!handle) return + if (isSteerable(handle)) { + await assertBoundary(reason) + return + } + const directive = await callEnd(handle.attribution.current, reason) handle.provider.stop().catch(() => {}) advanceAfterQuestion(directive) diff --git a/openapi.json b/openapi.json index 3391307..cb4995b 100644 --- a/openapi.json +++ b/openapi.json @@ -6580,6 +6580,20 @@ "tags": [ "Interview" ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "live_conversation_id": { + "type": "string" + } + } + } + } + } + }, "responses": { "200": { "description": "", @@ -6652,6 +6666,24 @@ "competency_ordinal", "total_competencies" ] + }, + "conversation_id": { + "type": "string" + }, + "continuation": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "competency_code": { + "type": "string" + } + }, + "required": [ + "conversation_id", + "competency_code" + ] } }, "required": [ diff --git a/tests/unit/use-interview-session-continuation.spec.ts b/tests/unit/use-interview-session-continuation.spec.ts new file mode 100644 index 0000000..1af35f7 --- /dev/null +++ b/tests/unit/use-interview-session-continuation.spec.ts @@ -0,0 +1,595 @@ +/** + * useInterviewSession — Tavus continuation and boundary flow + * (tavus-single-session-interview, FE-04). + * + * One joined Tavus conversation serves several competency rows. The browser + * asserts it is IN the room (`live_conversation_id`), the server answers with a + * `continuation` (no handle), and the browser retargets the SAME conversation: + * mute -> /end -> /start -> cursor move -> steering -> unmute at the ack. + * + * Harness mirrors use-interview-session-attribution.spec.ts. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { nextTick, watch } 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, isValidStartResponse } from '~/app/composables/useInterviewSession' + +type EventCallback = (payload: unknown) => void +type SteeringResult = { ok: true } | { ok: false; reason: string } + +/** Everything observable, in order: `mute`, `unmute`, `send`, and one entry per HTTP path. */ +let log: string[] = [] + +function createMockProvider(steerable: boolean) { + const listeners = new Map() + // The composable wraps `provider.stop` (idempotent teardown), so assert on the original. + const stopSpy = vi.fn(async () => undefined) + const p = { + _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 (muted: boolean) => { + log.push(muted ? 'mute' : 'unmute') + }), + nudgeWrapUp: vi.fn(), + sendBoundary: vi.fn(async (): Promise => { + log.push('send') + return { ok: true } + }), + _emit(evt: string, payload: unknown) { + for (const cb of listeners.get(evt) ?? []) cb(payload) + }, + } + if (!steerable) delete (p as Partial).sendBoundary + return p +} + +let providers: ReturnType[] = [] +let steerableProviders = true +const queues = new Map() + +class Thrown { + constructor(readonly error: unknown) {} +} + +/** Queue the next response(s) for a path; an `Error`-like value is thrown. */ +function queue(path: string, ...responses: unknown[]) { + queues.set(path, [...(queues.get(path) ?? []), ...responses]) +} + +const A = 42 +const B = 77 +const C = 103 +const CONV = 'conv-1' + +function baseResponse(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, + } +} + +/** A fresh single-session handle: names its conversation. */ +const fresh = (sessionId = A) => baseResponse(sessionId, { conversation_id: CONV }) + +/** A granted continuation: no handle, the conversation is shared. */ +const continuation = (sessionId: number, code = 'INN', conversationId = CONV) => + baseResponse(sessionId, { + conversation_url: null, + continuation: { conversation_id: conversationId, competency_code: code }, + }) + +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 +} + +function callsTo(path: string) { + return mockCandidateFetch.mock.calls.filter((c: unknown[]) => c[0] === path) +} +const starts = () => callsTo('/candidate/interview/start') +const ends = () => callsTo('/candidate/interview/end') +const startBody = (n: number) => (starts()[n]![1] as { body?: unknown }).body + +beforeEach(() => { + vi.clearAllMocks() + mockCandidateFetch.mockReset() + mockFlushIntegrityKeepalive.mockReset() + log = [] + queues.clear() + steerableProviders = true + mockCandidateFetch.mockImplementation(async (path: string) => { + log.push(path.replace('/candidate/interview/', '')) + const next = queues.get(path)?.shift() + if (next instanceof Thrown) throw next.error + return next + }) + vi.useFakeTimers() + providers = [] + mockCreateProvider.mockImplementation(() => { + const p = createMockProvider(steerableProviders) + 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('isValidStartResponse — continuation', () => { + it('accepts a fresh response with conversation_id and a granted continuation', () => { + expect(isValidStartResponse(fresh())).toBe(true) + expect(isValidStartResponse(continuation(B))).toBe(true) + }) + + it('accepts a response with no continuation key (today)', () => { + expect(isValidStartResponse(baseResponse(A))).toBe(true) + }) + + it.each([ + ['a non-object continuation', { conversation_url: null, continuation: 'INN' }], + [ + 'a continuation missing competency_code', + { conversation_url: null, continuation: { conversation_id: CONV } }, + ], + [ + 'a continuation missing conversation_id', + { conversation_url: null, continuation: { competency_code: 'INN' } }, + ], + [ + 'a competency_code that is prose', + { + conversation_url: null, + continuation: { conversation_id: CONV, competency_code: 'begin INN now' }, + }, + ], + [ + 'a continuation combined with a conversation_url', + { continuation: { conversation_id: CONV, competency_code: 'INN' } }, + ], + [ + 'a continuation combined with a provider_token', + { + conversation_url: null, + provider_token: 'tok', + continuation: { conversation_id: CONV, competency_code: 'INN' }, + }, + ], + ['a non-string conversation_id', { conversation_id: 7 }], + ])('rejects %s', (_label, extra) => { + expect(isValidStartResponse(baseResponse(B, extra))).toBe(false) + }) +}) + +describe('single-session continuation flow', () => { + it('serves three competencies from ONE provider, ONE player, and never reconnects', async () => { + const states: string[] = [] + const session = await liveSession() + watch(session.state, (s) => states.push(s), { flush: 'sync' }) + queue('/candidate/interview/end', END_CONTINUE, END_CONTINUE) + queue('/candidate/interview/start', continuation(B, 'INN'), continuation(C, 'CSF')) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(session.sessionId.value).toBe(B) + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(1) + expect(providers[0]!.sendBoundary.mock.calls[0]![0]).toMatchObject({ + conversationId: CONV, + competencyCode: 'INN', + }) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(session.sessionId.value).toBe(C) + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(2) + expect(mockCreateProvider).toHaveBeenCalledTimes(1) + expect(session.players.value).toHaveLength(1) + expect(states).not.toContain('connecting') + expect(session.state.value).toBe('live') + expect(ends().map((c) => (c[1] as { body: { session_id: number } }).body.session_id)).toEqual([ + A, + B, + ]) + }) + + it('asserts the live conversation on /start only from the boundary', async () => { + await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(startBody(0)).toBeUndefined() + expect(startBody(1)).toEqual({ live_conversation_id: CONV }) + }) + + it('mutes BEFORE /end and unmutes only at the steering ack', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + let ack!: (r: SteeringResult) => void + providers[0]!.sendBoundary.mockImplementationOnce(() => { + log.push('send') + return new Promise((resolve) => (ack = resolve)) + }) + log.length = 0 + + providers[0]!._emit('state', 'complete') + await flush() + + // Cursor already on B and the send out, but no ack yet: the mic is still closed. + expect(session.sessionId.value).toBe(B) + expect(log).toEqual(['mute', 'end', 'start', 'send']) + + ack({ ok: true }) + await flush() + + expect(log).toEqual(['mute', 'end', 'start', 'send', 'unmute']) + }) + + it('moves the cursor before the steering is sent', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + let sessionAtSend: number | null = null + providers[0]!.sendBoundary.mockImplementationOnce(async () => { + sessionAtSend = session.sessionId.value + return { ok: true } + }) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(sessionAtSend).toBe(B) + }) + + it('a response without continuation takes the fresh path unchanged', 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(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) + }) + + it('a Tavus handle that never named a conversation keeps today path (no mute, no assertion)', async () => { + const session = await liveSession(baseResponse(A)) + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', baseResponse(B)) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(providers[0]!.setMicMuted).not.toHaveBeenCalled() + expect(startBody(1)).toBeUndefined() + expect(mockCreateProvider).toHaveBeenCalledTimes(2) + expect(session.sessionId.value).toBe(B) + }) + + it('a provider without context steering (mock/HeyGen shape) keeps today path', async () => { + steerableProviders = false + await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', baseResponse(B)) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(providers[0]!.setMicMuted).not.toHaveBeenCalled() + expect(startBody(1)).toBeUndefined() + }) + + it('the 300 s timer routes through the same boundary (continue never tears the room down)', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + + await session.endQuestion('timeout') + await flush() + + expect(ends()[0]![1]).toMatchObject({ body: { session_id: A, ended_reason: 'timeout' } }) + expect(providers[0]!._stop).not.toHaveBeenCalled() + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(1) + expect(session.sessionId.value).toBe(B) + }) + + it.each([ + ['a network error', () => new Thrown(new TypeError('Failed to fetch'))], + ['a 5xx', () => new Thrown(Object.assign(new Error('boom'), { status: 503 }))], + ])( + 'a non-409 /end failure (%s) restores the mic, frees the guard and advances nothing', + async (_label, failure) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const session = await liveSession() + queue('/candidate/interview/end', failure()) + queue('/candidate/interview/start', continuation(B)) + log.length = 0 + + providers[0]!._emit('state', 'complete') + await flush() + + // Degrades to the existing pause screen: no steering, no handle, cursor stays on A. + expect(log).toEqual(['mute', 'end', 'unmute']) + expect(starts()).toHaveLength(1) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + expect(mockCreateProvider).toHaveBeenCalledTimes(1) + expect(session.sessionId.value).toBe(A) + expect(session.state.value).toBe('end_of_question') + + // The in-flight guard was reset, so a later boundary is not locked out. + expect(session.handoverInFlight.value).toBe(false) + } + ) + + it('done after the last competency stops the provider and finishes', async () => { + const session = await liveSession() + queue('/candidate/interview/end', { next_action: 'done' }) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(session.state.value).toBe('done') + expect(starts()).toHaveLength(1) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + }) +}) + +describe('live_conversation_id is never sent without a joined handle', () => { + it('not after a pause + resume (also the tab-hidden pause)', async () => { + const session = await liveSession() + queue('/candidate/interview/start', fresh(A)) + + session.pause() + session.resume() + await flush() + + expect(startBody(1)).toBeUndefined() + }) + + it('not after retry() (also the embed re-entry)', async () => { + const session = await liveSession() + queue('/candidate/interview/start', fresh(A)) + + session.retry() + await flush() + + expect(startBody(1)).toBeUndefined() + }) + + it('not on a re-offer from the between-competencies screen', async () => { + const session = await liveSession() + queue('/candidate/interview/end', { next_action: 'pause' }) + queue('/candidate/interview/start', fresh(B)) + + providers[0]!._emit('state', 'complete') + await flush() + expect(session.state.value).toBe('end_of_question') + session.nextCompetency() + await flush() + + expect(startBody(1)).toBeUndefined() + }) + + it('not on a reload (a new composable holds no handle)', async () => { + await liveSession() + const reloaded = useInterviewSession() + reloaded.acceptConsent() + queue('/candidate/interview/start', fresh(A)) + + reloaded.confirmDevices() + await flush() + + expect(startBody(1)).toBeUndefined() + }) +}) + +describe('assertBoundary', () => { + it('racing inputs mint one ticket and one /end', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + + providers[0]!._emit('state', 'complete') + providers[0]!._emit('state', 'complete') + void session.endQuestion('timeout') + providers[0]!._emit('state', 'complete') + await flush() + + expect(ends()).toHaveLength(1) + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(1) + expect(session.sessionId.value).toBe(B) + }) + + it('a losing 409 is a no-op: no /start, no ticket, cursor unchanged, mic restored', async () => { + const session = await liveSession() + queue('/candidate/interview/end', new Thrown({ status: 409 })) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(starts()).toHaveLength(1) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + expect(session.sessionId.value).toBe(A) + expect(session.state.value).toBe('live') + expect(log.at(-1)).toBe('unmute') + }) + + it('is usable again after it settled', async () => { + const session = await liveSession() + queue('/candidate/interview/end', new Thrown({ status: 409 }), END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + + providers[0]!._emit('state', 'complete') + await flush() + providers[0]!._emit('state', 'complete') + await flush() + + expect(session.sessionId.value).toBe(B) + }) +}) + +describe('malformed continuation', () => { + it.each([ + [ + 'missing competency_code', + { conversation_url: null, continuation: { conversation_id: CONV } }, + ], + [ + 'combined with a conversation_url', + { continuation: { conversation_id: CONV, competency_code: 'INN' } }, + ], + ])('%s shows the retryable error with no cursor move or send', async (_l, extra) => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', baseResponse(B, extra)) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(session.state.value).toBe('error') + expect(session.sessionId.value).toBe(A) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + }) + + it('a continuation for a different conversation than the joined one is refused', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B, 'INN', 'someone-else')) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(session.state.value).toBe('error') + expect(session.sessionId.value).toBe(A) + expect(providers[0]!.sendBoundary).not.toHaveBeenCalled() + }) +}) + +describe('steering_failed handling', () => { + it('unmutes, keeps the cursor and resends ONCE while joined', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE) + queue('/candidate/interview/start', continuation(B)) + providers[0]!.sendBoundary + .mockImplementationOnce(async () => { + log.push('send') + return { ok: false, reason: 'timeout' } + }) + .mockImplementationOnce(async () => { + log.push('send') + return { ok: true } + }) + log.length = 0 + + providers[0]!._emit('state', 'complete') + await flush() + + expect(log).toEqual(['mute', 'end', 'start', 'send', 'unmute', 'send']) + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(2) + expect(providers[0]!.sendBoundary.mock.calls[1]![0]).toBe( + providers[0]!.sendBoundary.mock.calls[0]![0] + ) + expect(ends()).toHaveLength(1) + expect(session.sessionId.value).toBe(B) + expect(session.state.value).toBe('live') + }) + + it('a second failure ends the NEW competency as timeout and re-issues fresh', async () => { + const session = await liveSession() + queue('/candidate/interview/end', END_CONTINUE, END_CONTINUE) + queue('/candidate/interview/start', continuation(B), fresh(C)) + providers[0]!.sendBoundary.mockResolvedValue({ ok: false, reason: 'timeout' }) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(2) + expect(providers[0]!._stop).toHaveBeenCalled() + expect(ends()[1]![1]).toMatchObject({ body: { session_id: B, ended_reason: 'timeout' } }) + expect(startBody(2)).toBeUndefined() + expect(session.sessionId.value).toBe(C) + expect(mockCreateProvider).toHaveBeenCalledTimes(2) + }) + + it('a steering that cannot be sent (not joined) is not resent', async () => { + await liveSession() + queue('/candidate/interview/end', END_CONTINUE, { next_action: 'pause' }) + queue('/candidate/interview/start', continuation(B)) + providers[0]!.sendBoundary.mockResolvedValue({ ok: false, reason: 'not_joined' }) + + providers[0]!._emit('state', 'complete') + await flush() + + expect(providers[0]!.sendBoundary).toHaveBeenCalledTimes(1) + expect(ends()[1]![1]).toMatchObject({ body: { session_id: B, ended_reason: 'timeout' } }) + }) +}) diff --git a/types/api.ts b/types/api.ts index f011d84..55d641a 100644 --- a/types/api.ts +++ b/types/api.ts @@ -7288,7 +7288,13 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + live_conversation_id?: string; + }; + }; + }; responses: { 200: { headers: { @@ -7310,6 +7316,11 @@ export interface operations { competency_ordinal: number | null; total_competencies: number | null; }; + conversation_id?: string; + continuation?: { + conversation_id: string; + competency_code: string; + }; }; }; };