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
19 changes: 17 additions & 2 deletions app/api/session/session-dispatch-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ const globalForInFlightDispatches = globalThis as typeof globalThis & {
const inFlightDispatches =
globalForInFlightDispatches.__liveavatarInFlightDispatches ??
(globalForInFlightDispatches.__liveavatarInFlightDispatches = new Map());
const DEFAULT_AGENT_DISPATCH_TIMEOUT_MS = 30_000;
const DEFAULT_AGENT_DISPATCH_TIMEOUT_MS = 45_000;
const DEFAULT_AGENT_DISPATCH_ATTEMPT_TIMEOUT_MS = 5_000;
const DEFAULT_PREWARM_TOTAL_TIMEOUT_MS = 45_000;

export type PrewarmPhase = 'room' | 'worker_readiness' | 'dispatch_readiness';
Expand Down Expand Up @@ -521,6 +522,10 @@ async function createAgentDispatchWithRetry(
const getDeadline = options.getDeadline || (() => fixedDeadline);
const retryMs = options.retryMs || readPositiveIntEnv('AGENT_DISPATCH_RETRY_MS', 500);
const pollMs = options.pollMs || readPositiveIntEnv('AGENT_DISPATCH_POLL_MS', 200);
const attemptTimeoutMs = readPositiveIntEnv(
'AGENT_DISPATCH_ATTEMPT_TIMEOUT_MS',
DEFAULT_AGENT_DISPATCH_ATTEMPT_TIMEOUT_MS
);
const sleepFn = options.sleep || sleep;
let lastError: unknown;
let attempts = 0;
Expand Down Expand Up @@ -561,12 +566,13 @@ async function createAgentDispatchWithRetry(
throw new RoomSessionCancelledError(session);
}

const attemptDeadline = Math.min(getDeadline(), Date.now() + attemptTimeoutMs);
const agentParticipant = await waitForReusableAgentParticipant(
roomClient,
roomName,
agentName,
reusableAgentOptions,
getDeadline,
() => Math.min(getDeadline(), attemptDeadline),
pollMs,
session,
sleepFn
Expand All @@ -581,6 +587,15 @@ async function createAgentDispatchWithRetry(
}

lastError = new Error('agent and required room inputs did not become ready');
await deleteDispatchQuietly(dispatchClient, dispatchId, roomName);
dispatchId = '';
const waitMs = Math.min(
calculateDispatchRetryDelay(attempts, retryMs),
remainingDispatchTime(getDeadline())
);
if (waitMs > 0) {
await sleepFn(waitMs);
}
} catch (error) {
if (error instanceof RoomSessionCancelledError) {
throw error;
Expand Down
8 changes: 5 additions & 3 deletions components/livekit/scroll-area/hooks/useAutoScroll.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useEffect, useRef } from 'react';
import { type RefObject, useEffect, useRef } from 'react';

const AUTO_SCROLL_THRESHOLD_PX = 100;

export function useAutoScroll(scrollContentContainer?: Element | null) {
export function useAutoScroll(scrollContentRef: RefObject<Element | null>) {
const isUserScrollingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasUserScrollIntentRef = useRef(false);

useEffect(() => {
const scrollContentContainer = scrollContentRef.current;

function scrollToBottom() {
if (!scrollContentContainer || isUserScrollingRef.current) return;

Expand Down Expand Up @@ -68,5 +70,5 @@ export function useAutoScroll(scrollContentContainer?: Element | null) {
}
};
}
}, [scrollContentContainer]);
}, [scrollContentRef]);
}
2 changes: 1 addition & 1 deletion components/livekit/scroll-area/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function ScrollArea({
}: ScrollAreaProps & React.HTMLAttributes<HTMLDivElement>) {
const scrollContentRef = useRef<HTMLDivElement>(null);

useAutoScroll(scrollContentRef.current);
useAutoScroll(scrollContentRef);

return (
<div ref={scrollContentRef} className={cn('overflow-y-scroll scroll-smooth', className)}>
Expand Down
13 changes: 13 additions & 0 deletions lib/transcription-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ export function mergeTranscriptionHistory(
finalValue === false ||
finalValue === 'true' ||
finalValue === 'false';
const entryIsPartial = finalValue === false || finalValue === 'false';

if (segmentId && entryIsPartial) {
const finalAlreadyExists = Array.from(byStreamId.values()).some((existing) => {
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 existingIsFinal = existingFinal === true || existingFinal === 'true';
return sameSegment && sameParticipant && existingIsFinal;
});
if (finalAlreadyExists) return;
}

if (segmentId && hasFinalState) {
for (const [streamId, existing] of byStreamId) {
Expand Down
22 changes: 22 additions & 0 deletions tests/chat-message-filter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,25 @@ test('transcription history survives a transient empty snapshot', () => {

assert.deepEqual(history, [preamble]);
});

test('a late one-character partial cannot reappear after its final segment', () => {
const partial = transcription('agent-partial', 'speech-agent-1', 100, '请', false);
const completed = transcription('agent-final', 'speech-agent-1', 110, '请稍等,我查一下。', true);
const nextUserTurn = transcription(
'user-final',
'speech-user-2',
200,
'帮我预订中会议室。',
true
);

const afterFinal = mergeTranscriptionHistory([], [completed]);
const afterLatePartial = mergeTranscriptionHistory(afterFinal, [partial]);
const afterNextTurn = mergeTranscriptionHistory(afterLatePartial, [nextUserTurn]);

assert.deepEqual(
afterNextTurn.map(({ text }) => text),
['请稍等,我查一下。', '帮我预订中会议室。']
);
assert.ok(afterNextTurn.every(({ text }) => text !== '请'));
});
12 changes: 12 additions & 0 deletions tests/scroll-area.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';

test('auto-scroll receives the mounted element ref instead of its initial null value', async () => {
const component = await readFile('components/livekit/scroll-area/scroll-area.tsx', 'utf8');
const hook = await readFile('components/livekit/scroll-area/hooks/useAutoScroll.ts', 'utf8');

assert.match(component, /useAutoScroll\(scrollContentRef\)/);
assert.doesNotMatch(component, /useAutoScroll\(scrollContentRef\.current\)/);
assert.match(hook, /const scrollContentContainer = scrollContentRef\.current/);
});
65 changes: 61 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 30s timeout while prewarm gets the default 45s total budget', async () => {
test('regular dispatch and prewarm both allow the default 45s total startup 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 30s timeout while prewarm gets the default 45s
),
/agent dispatch failed/
);
assert.equal(now - regularStartedAt, 30_000);
assert.equal(now - regularStartedAt, 45_000);

const prewarmStartedAt = now;
await assert.rejects(
Expand Down Expand Up @@ -414,6 +414,63 @@ test('regular dispatch keeps its 30s timeout while prewarm gets the default 45s
}
});

test('dispatch replaces an unassigned dispatch instead of waiting on it for the full deadline', async () => {
const originalNow = Date.now;
const originalAttemptTimeout = process.env.AGENT_DISPATCH_ATTEMPT_TIMEOUT_MS;
let now = 1_000;
let dispatchCount = 0;
const deletedDispatchIds = [];
const agentName = 'frontdesk-browser-agent-recovered-worker';
Date.now = () => now;
process.env.AGENT_DISPATCH_ATTEMPT_TIMEOUT_MS = '1000';

try {
const result = await dispatchRoomSession(
{
roomName: 'voice_assistant_room_recovered_worker',
sessionId: 'recovered-worker',
agentName,
readiness: { requireAgentSessionReady: true },
},
{
dispatchClient: {
async createDispatch() {
dispatchCount += 1;
return { id: `dispatch-recovered-worker-${dispatchCount}` };
},
async deleteDispatch(dispatchId) {
deletedDispatchIds.push(dispatchId);
},
},
roomClient: {
async listParticipants() {
return dispatchCount >= 2 ? readyParticipants(agentName) : [];
},
async deleteRoom() {},
},
dispatchTimeoutMs: 5_000,
dispatchPollMs: 500,
dispatchRetryMs: 500,
sleep: async (ms) => {
now += ms;
},
}
);

assert.equal(result.attempts, 2);
assert.equal(result.dispatchId, 'dispatch-recovered-worker-2');
assert.deepEqual(deletedDispatchIds, ['dispatch-recovered-worker-1']);
assert.equal(now, 2_500);
} finally {
Date.now = originalNow;
if (originalAttemptTimeout === undefined) {
delete process.env.AGENT_DISPATCH_ATTEMPT_TIMEOUT_MS;
} else {
process.env.AGENT_DISPATCH_ATTEMPT_TIMEOUT_MS = originalAttemptTimeout;
}
}
});

test('prewarm shares its 45s total budget across worker readiness and dispatch', async () => {
const originalNow = Date.now;
const originalPrewarmTimeout = process.env.LIVEAVATAR_PREWARM_TOTAL_TIMEOUT_MS;
Expand Down Expand Up @@ -1611,7 +1668,7 @@ test('a concurrent prewarm budget extends the shared in-flight dispatch', async
});
const results = await Promise.allSettled([regularDispatch, prewarmDispatch]);

assert.equal(dispatchCalls, 1);
assert.equal(dispatchCalls, 3);
assert.equal(now - startedAt, 20_000);
for (const result of results) {
assert.equal(result.status, 'rejected');
Expand Down Expand Up @@ -1684,7 +1741,7 @@ test('a prewarm budget can extend the dispatch during the old deadline check', a
releaseDeadlineCheck();
const results = await resultsPromise;

assert.equal(dispatchCalls, 1);
assert.equal(dispatchCalls, 4);
assert.equal(now, 28_000);
assert.equal(
results.every((result) => result.status === 'rejected'),
Expand Down
Loading