diff --git a/app/api/connection-details/route.ts b/app/api/connection-details/route.ts index 93f8e0aca..645dc0ebd 100644 --- a/app/api/connection-details/route.ts +++ b/app/api/connection-details/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk'; import { randomUUID } from 'node:crypto'; -import { RoomConfiguration } from '@livekit/protocol'; import { deriveLiveKitRoomName, resolveConnectionSessionId } from '@/lib/connection-room-id'; type ConnectionDetails = { @@ -32,12 +31,7 @@ export async function POST(req: Request) { throw new Error('LIVEKIT_API_SECRET is not defined'); } - // Parse room configuration from request body const body = await req.json(); - const roomConfig = body?.room_config - ? RoomConfiguration.fromJson(body.room_config, { ignoreUnknownFields: true }) - : new RoomConfiguration(); - const tokenRoomConfig = buildTokenRoomConfig(roomConfig); // Generate participant token const participantName = 'user'; @@ -45,10 +39,11 @@ export async function POST(req: Request) { const participantIdentity = `voice_assistant_user_${sessionId}`; const roomName = deriveLiveKitRoomName(sessionId); + // Explicit dispatch is handled by /api/session/dispatch. Omitting roomConfig + // also keeps participant tokens compatible with older LiveKit servers. const participantToken = await createParticipantToken( { identity: participantIdentity, name: participantName }, - roomName, - tokenRoomConfig + roomName ); // Return connection details @@ -76,11 +71,7 @@ export async function POST(req: Request) { } } -function createParticipantToken( - userInfo: AccessTokenOptions, - roomName: string, - roomConfig: RoomConfiguration | undefined -): Promise { +function createParticipantToken(userInfo: AccessTokenOptions, roomName: string): Promise { const at = new AccessToken(API_KEY, API_SECRET, { ...userInfo, ttl: '15m', @@ -94,21 +85,5 @@ function createParticipantToken( }; at.addGrant(grant); - if (roomConfig) { - at.roomConfig = roomConfig; - } - return at.toJwt(); } - -function buildTokenRoomConfig(roomConfig: RoomConfiguration) { - if (roomConfig.agents.length === 0) { - return roomConfig; - } - - // Explicit dispatch is handled by /api/session/dispatch; token agents would create duplicate jobs. - return new RoomConfiguration({ - ...roomConfig, - agents: [], - }); -} diff --git a/app/api/session/stop/route.ts b/app/api/session/stop/route.ts index bde8ec247..5a0f31cfd 100644 --- a/app/api/session/stop/route.ts +++ b/app/api/session/stop/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/connection-room-id'; import { executeRoomInputStopsSequentially, + isLiveKitRoomNotFoundError, resolveRoomInputStopUrls as resolveConfiguredRoomInputStopUrls, resolveLiveKitHttpUrl, } from '@/lib/session-stop'; @@ -252,6 +253,10 @@ async function deleteLiveKitRoom(roomName: string): Promise { await roomService.deleteRoom(roomName); return { target: 'livekit_room', ok: true }; } catch (error) { + if (isLiveKitRoomNotFoundError(error)) { + return { target: 'livekit_room', ok: true, skipped: true, status: 404 }; + } + return { target: 'livekit_room', ok: false, diff --git a/components/livekit/filtered-audio-renderer.tsx b/components/livekit/filtered-audio-renderer.tsx index 65e423c12..f5f6da12f 100644 --- a/components/livekit/filtered-audio-renderer.tsx +++ b/components/livekit/filtered-audio-renderer.tsx @@ -10,6 +10,7 @@ import { Track, } from 'livekit-client'; import { useRoomContext } from '@livekit/components-react'; +import { buildBrowserAudioPlaybackDiagnostics } from '@/lib/browser-audio-capture'; import { startMediaTrackAudioObserver } from '@/lib/frontend-audio-observer'; import { FRONTEND_EVENTS, @@ -398,6 +399,15 @@ export function FilteredAudioRenderer({ if (!playbackSource || playbackObserverStops.has(elementKey)) { return; } + const playbackDiagnostics = buildBrowserAudioPlaybackDiagnostics( + participantIdentity, + trackName, + audioElements.values(), + createdAudioElement + ); + const logPlaybackDiagnostics = + playbackDiagnostics.activeAudioElementCount === 1 ? console.info : console.warn; + logPlaybackDiagnostics('[browser-audio] playback diagnostics', playbackDiagnostics); pendingPlayback.delete(elementKey); startPlaybackObserver( elementKey, diff --git a/hooks/useBrowserSourceClient.ts b/hooks/useBrowserSourceClient.ts index 6f1dffc8d..4c450e33b 100644 --- a/hooks/useBrowserSourceClient.ts +++ b/hooks/useBrowserSourceClient.ts @@ -11,7 +11,13 @@ import { createLocalVideoTrack, } from 'livekit-client'; import type { AppConfig } from '@/app-config'; +import { + BROWSER_AUDIO_CONSTRAINTS, + assertBrowserEchoCancellationActive, + inspectBrowserAudioCapture, +} from '@/lib/browser-audio-capture'; import { BrowserAudioGateDevice } from '@/lib/browser-audio-gate-device'; +import { awaitBrowserMediaCapture } from '@/lib/browser-media-capture-timeout'; import { detachCurrentRuntime, isCurrentRuntime, @@ -33,12 +39,8 @@ const DEFAULT_BROWSER_MEDIA_STREAM_NAME = 'browser_input'; const BROWSER_VIDEO_DEFAULT_ENABLED = true; const BROWSER_VIDEO_STATS_INTERVAL_MS = 5000; const BROWSER_MEDIA_GATE_MAX_OPEN_LEASE_MS = 3000; -const BROWSER_AUDIO_CONSTRAINTS: MediaTrackConstraints = { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, -}; - +const BROWSER_VIDEO_CAPTURE_TIMEOUT_MS = 8000; +const BROWSER_VIDEO_PUBLISH_TIMEOUT_MS = 5000; interface BrowserSourceRuntime { audioTrack: LocalAudioTrack | null; videoTrack: LocalVideoTrack | null; @@ -163,6 +165,7 @@ export function useBrowserSourceClient( ); recordFrontendObservability(FRONTEND_EVENTS.BROWSER_AUDIO_CAPTURE_FINISHED); const captureTrack = audioTrack.mediaStreamTrack; + logBrowserAudioCaptureDiagnostics(captureTrack); audioTrack.mediaStreamTrack.enabled = false; try { @@ -281,15 +284,22 @@ export function useBrowserSourceClient( } recordFrontendObservability(FRONTEND_EVENTS.BROWSER_VIDEO_CAPTURE_STARTED); - const videoTrack = await createLocalVideoTrack({ - facingMode: 'user', - frameRate: { ideal: browserVideoFrameRate, max: browserVideoFrameRate }, - resolution: { - width: browserVideoWidth, - height: browserVideoHeight, - frameRate: browserVideoFrameRate, - }, - }); + const videoTrack = await awaitBrowserMediaCapture( + createLocalVideoTrack({ + facingMode: 'user', + frameRate: { ideal: browserVideoFrameRate, max: browserVideoFrameRate }, + resolution: { + width: browserVideoWidth, + height: browserVideoHeight, + frameRate: browserVideoFrameRate, + }, + }), + { + timeoutMs: BROWSER_VIDEO_CAPTURE_TIMEOUT_MS, + label: 'camera', + disposeLateResult: (track) => track.stop(), + } + ); recordFrontendObservability(FRONTEND_EVENTS.BROWSER_VIDEO_CAPTURE_FINISHED); videoTrack.mediaStreamTrack.enabled = runtime.videoEnabled; if (!isCurrentRuntime(runtimeRef, runtime)) { @@ -299,17 +309,26 @@ export function useBrowserSourceClient( try { recordFrontendObservability(FRONTEND_EVENTS.BROWSER_VIDEO_PUBLISH_STARTED); - const publication = await room.localParticipant.publishTrack(videoTrack, { - name: BROWSER_VIDEO_TRACK_NAME, - source: Track.Source.Camera, - stream: browserMediaStreamName, - simulcast: false, - degradationPreference: 'maintain-resolution', - videoEncoding: { - maxBitrate: browserVideoMaxBitrate, - maxFramerate: browserVideoFrameRate, - }, - }); + const publication = await awaitBrowserMediaCapture( + room.localParticipant.publishTrack(videoTrack, { + name: BROWSER_VIDEO_TRACK_NAME, + source: Track.Source.Camera, + stream: browserMediaStreamName, + simulcast: false, + degradationPreference: 'maintain-resolution', + videoEncoding: { + maxBitrate: browserVideoMaxBitrate, + maxFramerate: browserVideoFrameRate, + }, + }), + { + timeoutMs: BROWSER_VIDEO_PUBLISH_TIMEOUT_MS, + label: 'camera publish', + disposeLateResult: () => { + void room.localParticipant.unpublishTrack(videoTrack, true).catch(() => undefined); + }, + } + ); recordFrontendObservability(FRONTEND_EVENTS.BROWSER_VIDEO_PUBLISH_FINISHED); if (!isCurrentRuntime(runtimeRef, runtime)) { await room.localParticipant.unpublishTrack(videoTrack, true).catch(() => undefined); @@ -712,6 +731,15 @@ function buildAudioCaptureOptions(deviceId: string | null) { }; } +function logBrowserAudioCaptureDiagnostics(track: MediaStreamTrack) { + const diagnostics = inspectBrowserAudioCapture( + track, + navigator.mediaDevices.getSupportedConstraints() + ); + console.info('[browser-audio] capture diagnostics', diagnostics); + assertBrowserEchoCancellationActive(diagnostics); +} + function syncTrackEnabled(track: LocalAudioTrack | LocalVideoTrack | null, enabled: boolean) { if (!track) return; diff --git a/hooks/useRoom.ts b/hooks/useRoom.ts index 86f82ff45..caee0be1f 100644 --- a/hooks/useRoom.ts +++ b/hooks/useRoom.ts @@ -259,6 +259,11 @@ export function useRoom(appConfig: AppConfig) { try { await waitForAgentSessionStop(); + // A Room disconnect can make the welcome view visible without running the + // explicit End Call path. Clear the previous capture/gate runtime before + // reusing this Room, otherwise start() is a no-op and the new agent is + // dispatched into a room that never receives the browser microphone. + await browserSourceClient.stop(); await waitForRoomDisconnected(room); if (usesManagedRoomInput) { diff --git a/lib/browser-audio-capture.ts b/lib/browser-audio-capture.ts new file mode 100644 index 000000000..4317d181f --- /dev/null +++ b/lib/browser-audio-capture.ts @@ -0,0 +1,66 @@ +export const BROWSER_AUDIO_CONSTRAINTS: MediaTrackConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, +}; + +type InspectableAudioTrack = Pick; + +export interface BrowserAudioCaptureDiagnostics { + trackId: string; + supported: MediaTrackSupportedConstraints; + constraints: MediaTrackConstraints; + settings: MediaTrackSettings; +} + +export interface BrowserAudioPlaybackDiagnostics { + participantIdentity: string; + trackName: string; + activeAudioElementCount: number; + paused: boolean; + readyState: number; +} + +type BrowserAudioElementState = Pick; + +export function buildBrowserAudioPlaybackDiagnostics( + participantIdentity: string, + trackName: string, + audioElements: Iterable, + currentElement: BrowserAudioElementState +): BrowserAudioPlaybackDiagnostics { + return { + participantIdentity, + trackName, + activeAudioElementCount: Array.from(audioElements).filter( + (element) => !element.paused && !element.ended && element.readyState >= 2 + ).length, + paused: currentElement.paused, + readyState: currentElement.readyState, + }; +} + +export function inspectBrowserAudioCapture( + track: InspectableAudioTrack, + supported: MediaTrackSupportedConstraints +): BrowserAudioCaptureDiagnostics { + const constraints = track.getConstraints(); + const settings = track.getSettings(); + + return { + trackId: track.id, + supported, + constraints, + settings, + }; +} + +export function assertBrowserEchoCancellationActive( + diagnostics: BrowserAudioCaptureDiagnostics +): void { + if (diagnostics.supported.echoCancellation && diagnostics.settings.echoCancellation !== true) { + throw new Error( + 'Browser echo cancellation was requested but is not active on the microphone track.' + ); + } +} diff --git a/lib/browser-media-capture-timeout.ts b/lib/browser-media-capture-timeout.ts new file mode 100644 index 000000000..40a113d5b --- /dev/null +++ b/lib/browser-media-capture-timeout.ts @@ -0,0 +1,40 @@ +export class BrowserMediaCaptureTimeoutError extends Error { + constructor(label: string, timeoutMs: number) { + super(`${label} capture did not become ready within ${timeoutMs}ms`); + this.name = 'BrowserMediaCaptureTimeoutError'; + } +} + +type BrowserMediaCaptureOptions = { + timeoutMs: number; + label: string; + disposeLateResult: (result: T) => void; +}; + +export async function awaitBrowserMediaCapture( + capture: Promise, + options: BrowserMediaCaptureOptions +): Promise { + let timeoutHandle: ReturnType | null = null; + let timedOut = false; + + void capture.then( + (result) => { + if (timedOut) options.disposeLateResult(result); + }, + () => undefined + ); + + const timeout = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => { + timedOut = true; + reject(new BrowserMediaCaptureTimeoutError(options.label, options.timeoutMs)); + }, options.timeoutMs); + }); + + try { + return await Promise.race([capture, timeout]); + } finally { + if (timeoutHandle !== null) clearTimeout(timeoutHandle); + } +} diff --git a/lib/livekit-media-gate.ts b/lib/livekit-media-gate.ts index 0c2278861..16c90a62b 100644 --- a/lib/livekit-media-gate.ts +++ b/lib/livekit-media-gate.ts @@ -1,4 +1,5 @@ import { type RemoteParticipant, type Room, RoomEvent } from 'livekit-client'; +import { awaitBrowserMediaCapture } from './browser-media-capture-timeout'; import { MEDIA_CONTROL_TOPIC, MEDIA_STATE_TOPIC, @@ -8,6 +9,8 @@ import { encodeMediaState, } from './media-control-protocol'; +const MEDIA_STATE_PUBLISH_TIMEOUT_MS = 3000; + export type MediaGateExecutorPort = { start(): Promise; bindController(controllerIdentity: string): Promise; @@ -228,11 +231,18 @@ export async function publishLiveKitMediaState( throwIfAborted(signal); const payload = encodeMediaState(state); throwIfAborted(signal); - await room.localParticipant.publishData(payload, { - reliable: true, - destinationIdentities: [controllerIdentity], - topic: MEDIA_STATE_TOPIC, - }); + await awaitBrowserMediaCapture( + room.localParticipant.publishData(payload, { + reliable: true, + destinationIdentities: [controllerIdentity], + topic: MEDIA_STATE_TOPIC, + }), + { + timeoutMs: MEDIA_STATE_PUBLISH_TIMEOUT_MS, + label: 'media gate state', + disposeLateResult: () => undefined, + } + ); } function mediaControlErrorCode(error: unknown): string { diff --git a/lib/media-control-protocol.ts b/lib/media-control-protocol.ts index e022d9c95..a89477ad5 100644 --- a/lib/media-control-protocol.ts +++ b/lib/media-control-protocol.ts @@ -246,7 +246,7 @@ export function decodeMediaControl(payload: Uint8Array | string): MediaControlCo return Object.freeze(command); } -export function encodeMediaState(message: MediaStateSnapshot): Uint8Array { +export function encodeMediaState(message: MediaStateSnapshot): Uint8Array { const values = requireObject(message); validateMediaState(values); const ordered = Object.fromEntries( diff --git a/lib/observability.ts b/lib/observability.ts index 9c98e41a0..b89b21557 100644 --- a/lib/observability.ts +++ b/lib/observability.ts @@ -77,7 +77,7 @@ export type PublishableRoom = { localParticipant?: { identity?: string; publishData?: ( - data: Uint8Array, + data: Uint8Array, options?: { reliable?: boolean; topic?: string } ) => Promise | void; }; diff --git a/lib/session-stop.ts b/lib/session-stop.ts index d2a0d1898..9ce240de1 100644 --- a/lib/session-stop.ts +++ b/lib/session-stop.ts @@ -24,6 +24,15 @@ export function resolveLiveKitHttpUrl(liveKitUrl?: string | null): string | unde return normalized; } +export function isLiveKitRoomNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + const { status, code } = error as { status?: unknown; code?: unknown }; + return status === 404 && code === 'not_found'; +} + export function normalizeRoomInputControlUrl( rawUrl: string, action: RoomInputControlAction diff --git a/lib/transcription-history.ts b/lib/transcription-history.ts index 2a16a8da8..1dbe2384f 100644 --- a/lib/transcription-history.ts +++ b/lib/transcription-history.ts @@ -15,7 +15,30 @@ export function mergeTranscriptionHistory( if (current.length === 0) return previous; const byStreamId = new Map(previous.map((entry) => [entry.streamInfo.id, entry])); - current.forEach((entry) => byStreamId.set(entry.streamInfo.id, entry)); + current.forEach((entry) => { + const segmentId = entry.streamInfo.attributes?.['lk.segment_id']; + const finalValue: unknown = entry.streamInfo.attributes?.['lk.transcription_final']; + const hasFinalState = + finalValue === true || + finalValue === false || + finalValue === 'true' || + finalValue === 'false'; + + if (segmentId && hasFinalState) { + for (const [streamId, existing] of byStreamId) { + const sameSegment = existing.streamInfo.attributes?.['lk.segment_id'] === segmentId; + const sameParticipant = + existing.participantInfo.identity === entry.participantInfo.identity; + const existingFinal: unknown = existing.streamInfo.attributes?.['lk.transcription_final']; + const existingIsPartial = existingFinal === false || existingFinal === 'false'; + if (sameSegment && sameParticipant && existingIsPartial) { + byStreamId.delete(streamId); + } + } + } + + byStreamId.set(entry.streamInfo.id, entry); + }); return Array.from(byStreamId.values()) .sort((a, b) => a.streamInfo.timestamp - b.streamInfo.timestamp) diff --git a/tests/browser-audio-capture.test.mjs b/tests/browser-audio-capture.test.mjs new file mode 100644 index 000000000..292fe1b63 --- /dev/null +++ b/tests/browser-audio-capture.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const { + BROWSER_AUDIO_CONSTRAINTS, + assertBrowserEchoCancellationActive, + buildBrowserAudioPlaybackDiagnostics, + inspectBrowserAudioCapture, +} = await import('../lib/browser-audio-capture.ts'); + +test('browser audio capture requests WebRTC audio processing', () => { + assert.deepEqual(BROWSER_AUDIO_CONSTRAINTS, { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }); +}); + +test('browser audio capture reports requested and effective settings', () => { + const diagnostics = inspectBrowserAudioCapture( + { + id: 'audio-track-1', + getConstraints: () => BROWSER_AUDIO_CONSTRAINTS, + getSettings: () => ({ + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + channelCount: 1, + sampleRate: 48000, + }), + }, + { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + } + ); + + assert.equal(diagnostics.trackId, 'audio-track-1'); + assert.equal(diagnostics.settings.echoCancellation, true); + assert.equal(diagnostics.settings.noiseSuppression, true); + assert.deepEqual(diagnostics.constraints, BROWSER_AUDIO_CONSTRAINTS); +}); + +test('browser audio capture fails when supported AEC is not effective', () => { + const diagnostics = inspectBrowserAudioCapture( + { + id: 'audio-track-2', + getConstraints: () => BROWSER_AUDIO_CONSTRAINTS, + getSettings: () => ({ echoCancellation: false }), + }, + { echoCancellation: true } + ); + + assert.equal(diagnostics.settings.echoCancellation, false); + assert.throws( + () => assertBrowserEchoCancellationActive(diagnostics), + /echo cancellation was requested but is not active/i + ); +}); + +test('browser audio capture does not claim unsupported AEC is active', () => { + const diagnostics = inspectBrowserAudioCapture( + { + id: 'audio-track-3', + getConstraints: () => BROWSER_AUDIO_CONSTRAINTS, + getSettings: () => ({}), + }, + { echoCancellation: false } + ); + + assert.equal(diagnostics.supported.echoCancellation, false); + assert.equal(diagnostics.settings.echoCancellation, undefined); + assert.doesNotThrow(() => assertBrowserEchoCancellationActive(diagnostics)); +}); + +test('browser playback diagnostics identify the active output and element count', () => { + const playingElement = { paused: false, ended: false, readyState: 4 }; + const pausedElement = { paused: true, ended: false, readyState: 4 }; + + assert.deepEqual( + buildBrowserAudioPlaybackDiagnostics( + 'agent-AJ_123', + 'roomio_audio', + [playingElement, pausedElement], + playingElement + ), + { + participantIdentity: 'agent-AJ_123', + trackName: 'roomio_audio', + activeAudioElementCount: 1, + paused: false, + readyState: 4, + } + ); +}); diff --git a/tests/browser-media-capture-timeout.test.mjs b/tests/browser-media-capture-timeout.test.mjs new file mode 100644 index 000000000..8a2421cd7 --- /dev/null +++ b/tests/browser-media-capture-timeout.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { + BrowserMediaCaptureTimeoutError, + awaitBrowserMediaCapture, +} from '../lib/browser-media-capture-timeout.ts'; + +test('browser media capture times out instead of leaving Start Call pending forever', async () => { + let resolveCapture; + let stopped = false; + const capture = new Promise((resolve) => { + resolveCapture = resolve; + }); + + await assert.rejects( + awaitBrowserMediaCapture(capture, { + timeoutMs: 5, + label: 'camera', + disposeLateResult: (track) => track.stop(), + }), + (error) => + error instanceof BrowserMediaCaptureTimeoutError && + error.message === 'camera capture did not become ready within 5ms' + ); + + resolveCapture({ stop: () => (stopped = true) }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(stopped, true, 'a track that arrives after timeout must be stopped'); +}); + +test('browser media capture returns an on-time result without disposing it', async () => { + let stopped = false; + const track = { stop: () => (stopped = true) }; + + assert.equal( + await awaitBrowserMediaCapture(Promise.resolve(track), { + timeoutMs: 50, + label: 'camera', + disposeLateResult: (value) => value.stop(), + }), + track + ); + assert.equal(stopped, false); +}); + +test('browser video capture and publication are both bounded startup steps', async () => { + const source = await readFile( + new URL('../hooks/useBrowserSourceClient.ts', import.meta.url), + 'utf8' + ); + + assert.match(source, /label: 'camera'/); + assert.match(source, /label: 'camera publish'/); +}); diff --git a/tests/browser-room-session.test.mjs b/tests/browser-room-session.test.mjs index e19d1ab0c..7993a418b 100644 --- a/tests/browser-room-session.test.mjs +++ b/tests/browser-room-session.test.mjs @@ -167,3 +167,12 @@ test('browser input starts media and dispatch concurrently', async () => { /appConfig\.sandboxId && videoEnabledRef\.current[\s\S]*Promise\.allSettled\(\[[\s\S]*ensureAudioPublished\(runtime\),[\s\S]*ensureVideoPublished\(runtime\)[\s\S]*\]\)/ ); }); + +test('a restarted session tears down stale browser media before reconnecting', async () => { + const useRoomSource = await readFile(new URL('../hooks/useRoom.ts', import.meta.url), 'utf8'); + assert.match( + useRoomSource, + /try \{\n await waitForAgentSessionStop\(\);[\s\S]*?await browserSourceClient\.stop\(\);\n await waitForRoomDisconnected\(room\);/, + 'stale browser capture and gate state must be cleared before the Room is reused' + ); +}); diff --git a/tests/chat-message-filter.test.mjs b/tests/chat-message-filter.test.mjs index 632df3fcd..3fb61d2fb 100644 --- a/tests/chat-message-filter.test.mjs +++ b/tests/chat-message-filter.test.mjs @@ -5,14 +5,17 @@ import { test } from 'node:test'; const { isRenderableChatMessage } = await import('../lib/chat-message-filter.ts'); const { mergeTranscriptionHistory } = await import('../lib/transcription-history.ts'); -function transcription(id, segmentId, timestamp, text) { +function transcription(id, segmentId, timestamp, text, final) { return { text, participantInfo: { identity: 'frontdesk-agent' }, streamInfo: { id, timestamp, - attributes: { 'lk.segment_id': segmentId }, + attributes: { + 'lk.segment_id': segmentId, + ...(final === undefined ? {} : { 'lk.transcription_final': final }), + }, }, }; } @@ -55,6 +58,34 @@ test('transcription history updates partial text without duplicating one stream' assert.equal(history[0].text, '我查一下。'); }); +test('transcription history replaces cross-stream partials with the final segment', () => { + const partials = [ + transcription('partial-1', 'speech-user-1', 100, '帮我', false), + transcription('partial-2', 'speech-user-1', 110, '帮我预定', false), + transcription('partial-3', 'speech-user-1', 120, '帮我预定一个', false), + ]; + const final = transcription('final-1', 'speech-user-1', 130, '帮我预定一个。', true); + + const history = mergeTranscriptionHistory(partials, [final]); + + assert.deepEqual( + history.map(({ text }) => text), + ['帮我预定一个。'] + ); +}); + +test('transcription history keeps distinct completed streams for one agent segment', () => { + const preamble = transcription('final-preamble', 'speech-agent-1', 100, '我查一下。', true); + const answer = transcription('final-answer', 'speech-agent-1', 200, '已经设置好了。', true); + + const history = mergeTranscriptionHistory([preamble], [answer]); + + assert.deepEqual( + history.map(({ text }) => text), + ['我查一下。', '已经设置好了。'] + ); +}); + test('transcription history survives a transient empty snapshot', () => { const preamble = transcription('stream-1', 'speech-1', 100, '我查一下。'); diff --git a/tests/session-start-dispatch.test.mjs b/tests/session-start-dispatch.test.mjs index a3fe9d830..8a5d7ce67 100644 --- a/tests/session-start-dispatch.test.mjs +++ b/tests/session-start-dispatch.test.mjs @@ -15,15 +15,14 @@ test('connection details route does not dispatch agents while generating tokens' assert.doesNotMatch(routeSource, /dispatchClient\.createDispatch/); }); -test('connection details route strips room-config agents from the participant token', async () => { +test('connection details route omits room config from the participant token', async () => { const routeSource = await readFile( new URL('../app/api/connection-details/route.ts', import.meta.url), 'utf8' ); - assert.match(routeSource, /function buildTokenRoomConfig/); - assert.match(routeSource, /RoomConfiguration\.fromJson/); - assert.match(routeSource, /agents: \[\]/); + assert.doesNotMatch(routeSource, /RoomConfiguration\.fromJson/); + assert.doesNotMatch(routeSource, /at\.roomConfig/); assert.match(routeSource, /Explicit dispatch is handled by \/api\/session\/dispatch/); assert.match(routeSource, /resolveConnectionSessionId/); assert.match(routeSource, /deriveLiveKitRoomName/); @@ -280,7 +279,7 @@ test('start call reconnects only after any previous room disconnect has complete assert.match(useRoomSource, /waitForRoomDisconnected/); assert.match( useRoomSource, - /await waitForAgentSessionStop\(\);\s*await waitForRoomDisconnected\(room\);/ + /await waitForAgentSessionStop\(\);[\s\S]*?await browserSourceClient\.stop\(\);\s*await waitForRoomDisconnected\(room\);/ ); }); diff --git a/tests/session-stop.test.mjs b/tests/session-stop.test.mjs index 4ee4c06a5..13bde81ba 100644 --- a/tests/session-stop.test.mjs +++ b/tests/session-stop.test.mjs @@ -5,6 +5,7 @@ import { POST as stopSession } from '../app/api/session/stop/route.ts'; import { readAgentWorkerStateFromLog } from '../lib/agent-worker-readiness.ts'; import { executeRoomInputStopsSequentially, + isLiveKitRoomNotFoundError, resolveLiveKitHttpUrl, resolveRoomInputStopUrls, } from '../lib/session-stop.ts'; @@ -44,6 +45,35 @@ test('maps livekit websocket URLs to server API URLs', () => { assert.equal(resolveLiveKitHttpUrl('https://livekit.example'), 'https://livekit.example'); }); +test('recognizes only LiveKit room-not-found errors as an idempotent stop', () => { + assert.equal( + isLiveKitRoomNotFoundError({ + status: 404, + code: 'not_found', + message: 'requested room does not exist', + }), + true + ); + assert.equal(isLiveKitRoomNotFoundError({ status: 404, code: 'permission_denied' }), false); + assert.equal(isLiveKitRoomNotFoundError({ status: 500, code: 'not_found' }), false); + assert.equal(isLiveKitRoomNotFoundError(new Error('requested room does not exist')), false); +}); + +test('session stop treats an already deleted LiveKit room as stopped', async () => { + const routeSource = await readFile( + new URL('../app/api/session/stop/route.ts', import.meta.url), + 'utf8' + ); + const deleteRoomSource = routeSource.match(/async function deleteLiveKitRoom[\s\S]*?\n}/)?.[0]; + + assert.ok(deleteRoomSource, 'deleteLiveKitRoom should be defined'); + assert.match(deleteRoomSource, /isLiveKitRoomNotFoundError\(error\)/); + assert.match( + deleteRoomSource, + /target:\s*'livekit_room',\s*ok:\s*true,\s*skipped:\s*true,\s*status:\s*404/ + ); +}); + test('room input stop URL resolver skips browser input', () => { assert.deepEqual( resolveRoomInputStopUrls({