Skip to content
Open
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
8 changes: 4 additions & 4 deletions app/api/session/dispatch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export async function POST(req: Request) {
agent_name?: string;
sessionId?: string;
session_id?: string;
requireRoomVideoInputReady?: boolean;
require_room_video_input_ready?: boolean;
requireAgentSessionReady?: boolean;
require_agent_session_ready?: boolean;
};
try {
body = await req.json();
Expand Down Expand Up @@ -57,8 +57,8 @@ export async function POST(req: Request) {
sessionId,
agentName,
readiness: {
requireRoomVideoInputReady:
body.requireRoomVideoInputReady === true || body.require_room_video_input_ready === true,
requireAgentSessionReady:
body.requireAgentSessionReady === true || body.require_agent_session_ready === true,
},
});
return NextResponse.json({ status: 'dispatched', roomName, agentName, sessionId, dispatch });
Expand Down
3 changes: 1 addition & 2 deletions app/api/session/session-dispatch-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const globalForInFlightDispatches = globalThis as typeof globalThis & {
const inFlightDispatches =
globalForInFlightDispatches.__liveavatarInFlightDispatches ??
(globalForInFlightDispatches.__liveavatarInFlightDispatches = new Map());
const DEFAULT_AGENT_DISPATCH_TIMEOUT_MS = 8_000;
const DEFAULT_AGENT_DISPATCH_TIMEOUT_MS = 30_000;
const DEFAULT_PREWARM_TOTAL_TIMEOUT_MS = 45_000;

export type PrewarmPhase = 'room' | 'worker_readiness' | 'dispatch_readiness';
Expand Down Expand Up @@ -302,7 +302,6 @@ export async function prewarmRoomSession(
...request,
readiness: {
requireAgentSessionReady: true,
requireRoomInputParticipantsReady: true,
},
},
{
Expand Down
11 changes: 2 additions & 9 deletions hooks/useRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useBrowserSourceClient } from '@/hooks/useBrowserSourceClient';
import { getVoiceSessionId, resetVoiceSessionId } from '@/lib/browser-room-session';
import { readConnectionDetailsResponse } from '@/lib/connection-details-response';
import { isValidConnectionRoomId } from '@/lib/connection-room-id';
import { usesServerRoomInputDevice } from '@/lib/input-device-config';
import {
FRONTEND_EVENTS,
beginFrontendObservabilitySession,
Expand All @@ -27,12 +26,6 @@ import {
waitForAgentSessionStop,
} from '@/lib/session-stop-client';

function requiresRoomVideoInputReady(appConfig: AppConfig) {
return appConfig.visionInputDevice
? usesServerRoomInputDevice(appConfig.visionInputDevice)
: false;
}

export function useRoom(appConfig: AppConfig) {
const aborted = useRef(false);
const sessionIdRef = useRef<string | null>(null);
Expand Down Expand Up @@ -212,15 +205,14 @@ export function useRoom(appConfig: AppConfig) {
await recoverFromStartError(error);
};

setIsSessionActive(true);
beginFrontendObservabilitySession(room);

const dispatchAgentSession = async () => {
recordFrontendObservability(FRONTEND_EVENTS.DISPATCH_STARTED);
dispatchSessionId = sessionId;
const signal = beginAgentSessionStart(room.name, sessionId);
const dispatchPromise = requestAgentSessionDispatch(appConfig.agentName, sessionId, {
requireRoomVideoInputReady: requiresRoomVideoInputReady(appConfig),
requireAgentSessionReady: usesManagedRoomInput,
signal,
});
registerAgentSessionDispatch(room.name, sessionId, dispatchPromise);
Expand Down Expand Up @@ -310,6 +302,7 @@ export function useRoom(appConfig: AppConfig) {
if (!usesSandboxConcurrentStartup) {
await dispatchAgentSession();
}
setIsSessionActive(true);
} catch (error) {
await handleStartError(error);
}
Expand Down
4 changes: 2 additions & 2 deletions lib/session-dispatch-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
type DispatchOptions = {
signal?: AbortSignal;
requireRoomVideoInputReady?: boolean;
requireAgentSessionReady?: boolean;
};

export class AgentSessionDispatchCancelledError extends Error {
Expand All @@ -27,7 +27,7 @@ export async function requestAgentSessionDispatch(
body: JSON.stringify({
agentName: normalizedAgentName,
sessionId: normalizedSessionId,
...(options.requireRoomVideoInputReady ? { requireRoomVideoInputReady: true } : {}),
...(options.requireAgentSessionReady ? { requireAgentSessionReady: true } : {}),
}),
signal: options.signal,
});
Expand Down
8 changes: 7 additions & 1 deletion lib/session-dispatch-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type ReusableAgentParticipantOptions = AgentParticipantMatchOptions & {
};

export const AGENT_SESSION_READY_ATTRIBUTE = 'liveavatar.agent.session_ready';
// livekit-server-sdk maps protobuf attribute keys to camelCase object keys.
const AGENT_SESSION_READY_ATTRIBUTE_CAMEL = 'liveavatarAgentSessionReady';

const ROOM_AUDIO_INPUT_IDENTITY = 'room_audio_input';
const ROOM_VIDEO_INPUT_IDENTITY = 'room_video_input';
Expand Down Expand Up @@ -127,7 +129,11 @@ function isExpectedAgentParticipant(participant: ParticipantInfo, agentName: str
}

function isAgentSessionReady(participant: ParticipantInfo) {
return participant.attributes?.[AGENT_SESSION_READY_ATTRIBUTE] === 'true';
const attributes = participant.attributes ?? {};
return (
attributes[AGENT_SESSION_READY_ATTRIBUTE] === 'true' ||
attributes[AGENT_SESSION_READY_ATTRIBUTE_CAMEL] === 'true'
);
}

function isAnonymousLiveKitAgentParticipant(participant: ParticipantInfo) {
Expand Down
6 changes: 3 additions & 3 deletions tests/session-dispatch-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test('agent session dispatch sends only canonical session id to Next API', async
}
});

test('agent session dispatch can require room video input readiness', async () => {
test('agent session dispatch can require authoritative session readiness', async () => {
const originalFetch = globalThis.fetch;
let postedBody;
globalThis.fetch = async (_url, init) => {
Expand All @@ -55,13 +55,13 @@ test('agent session dispatch can require room video input readiness', async () =
const { requestAgentSessionDispatch } = await loadSessionDispatchClientModule();

await requestAgentSessionDispatch('agent-a', '11111111-2222-4333-8444-555555555555', {
requireRoomVideoInputReady: true,
requireAgentSessionReady: true,
});

assert.deepEqual(postedBody, {
agentName: 'agent-a',
sessionId: '11111111-2222-4333-8444-555555555555',
requireRoomVideoInputReady: true,
requireAgentSessionReady: true,
});
} finally {
globalThis.fetch = originalFetch;
Expand Down
18 changes: 18 additions & 0 deletions tests/session-dispatch-readiness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ test('dispatch can require room video input readiness before reusing an agent',
);
});

test('dispatch accepts the server SDK camel-cased agent ready attribute', () => {
const agent = participant({
identity: 'agent-AJ_ready',
kind: ParticipantInfo_Kind.AGENT,
attributes: {
lkAgentName: 'frontdesk-agent',
liveavatarAgentSessionReady: 'true',
},
});

assert.equal(
findReusableAgentParticipant([agent], 'frontdesk-agent', {
requireAgentSessionReady: true,
}),
agent
);
});

test('dispatch can reuse an active agent once room video input is publishing', () => {
const agent = participant({
identity: 'agent-AJ_running',
Expand Down
8 changes: 4 additions & 4 deletions tests/session-prewarm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ test('missing LiveKit configuration fails before registering a room session', as
}
});

test('regular dispatch keeps its 8s timeout while prewarm gets the default 45s total budget', async () => {
test('regular dispatch keeps its 30s timeout while prewarm gets the default 45s total budget', async () => {
const originalNow = Date.now;
const originalTimeout = process.env.AGENT_DISPATCH_TIMEOUT_MS;
const originalPrewarmTimeout = process.env.LIVEAVATAR_PREWARM_TOTAL_TIMEOUT_MS;
Expand Down Expand Up @@ -384,7 +384,7 @@ test('regular dispatch keeps its 8s timeout while prewarm gets the default 45s t
),
/agent dispatch failed/
);
assert.equal(now - regularStartedAt, 8_000);
assert.equal(now - regularStartedAt, 30_000);

const prewarmStartedAt = now;
await assert.rejects(
Expand Down Expand Up @@ -1322,7 +1322,7 @@ test('shared dispatch token stays active through per-caller readiness waits', as
assert.doesNotMatch(readinessSource, /beginRoomSessionDispatch|finishRoomSessionDispatch/);
});

test('prewarm waits for the agent session and both room input participants', async () => {
test('prewarm completes when the agent session is ready without waiting for optional video input', async () => {
const agentName = 'frontdesk-browser-agent-readiness';
let roomCreated = false;
let workerReady = false;
Expand Down Expand Up @@ -1395,7 +1395,7 @@ test('prewarm waits for the agent session and both room input participants', asy
assert.deepEqual(result.readiness, {
agentSessionReady: true,
audioParticipantReady: true,
visionParticipantReady: true,
visionParticipantReady: false,
});
});

Expand Down
13 changes: 8 additions & 5 deletions tests/session-start-dispatch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ test('session dispatch route only accepts anonymous LiveKit agent fallback after
serviceSource,
/const alreadyJoined = await findReusableAgentParticipant\(\s*roomClient,\s*roomName,\s*agentName,\s*reusableAgentOptions\s*\);/
);
assert.match(routeSource, /requireRoomVideoInputReady/);
assert.match(routeSource, /require_room_video_input_ready/);
assert.match(routeSource, /requireAgentSessionReady/);
assert.match(routeSource, /require_agent_session_ready/);
assert.match(readinessSource, /type AgentParticipantMatchOptions/);
assert.match(readinessSource, /type ReusableAgentParticipantOptions/);
assert.match(readinessSource, /allowAnonymousLiveKitAgentFallback/);
Expand Down Expand Up @@ -207,9 +207,12 @@ test('start call dispatches the agent with a cancellable room session id', async
assert.match(useRoomSource, /isExpectedStartCancellation/);
assert.match(useRoomSource, /waitForAgentSessionStop/);
assert.match(useRoomSource, /requestAgentSessionDispatch\(\s*appConfig\.agentName,\s*sessionId,/);
assert.match(
useRoomSource,
/requireRoomVideoInputReady: requiresRoomVideoInputReady\(appConfig\)/
assert.match(useRoomSource, /requireAgentSessionReady: usesManagedRoomInput/);
assert.match(useRoomSource, /await Promise\.allSettled\(\[/);
assert.ok(
useRoomSource.lastIndexOf('setIsSessionActive(true)') >
useRoomSource.lastIndexOf('await dispatchAgentSession()'),
'session view must become active only after dispatch readiness completes'
);
assert.doesNotMatch(useRoomSource, /requestAgentSessionDispatch\(\s*room\.name,/);
});
Expand Down
Loading