From 944c2952c72a01b2bf293fce6b9a0b28a0075ebb Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 12:57:20 +0800 Subject: [PATCH 01/12] fix: wait for managed input before activating session --- app/api/session/dispatch/route.ts | 9 +++++++++ hooks/useRoom.ts | 4 +++- lib/session-dispatch-client.ts | 6 ++++++ tests/session-dispatch-client.test.mjs | 6 +++++- tests/session-start-dispatch.test.mjs | 11 +++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/api/session/dispatch/route.ts b/app/api/session/dispatch/route.ts index 982817d87..a3f51ae5b 100644 --- a/app/api/session/dispatch/route.ts +++ b/app/api/session/dispatch/route.ts @@ -20,6 +20,10 @@ export async function POST(req: Request) { agent_name?: string; sessionId?: string; session_id?: string; + requireAgentSessionReady?: boolean; + require_agent_session_ready?: boolean; + requireRoomInputParticipantsReady?: boolean; + require_room_input_participants_ready?: boolean; requireRoomVideoInputReady?: boolean; require_room_video_input_ready?: boolean; }; @@ -57,6 +61,11 @@ export async function POST(req: Request) { sessionId, agentName, readiness: { + requireAgentSessionReady: + body.requireAgentSessionReady === true || body.require_agent_session_ready === true, + requireRoomInputParticipantsReady: + body.requireRoomInputParticipantsReady === true || + body.require_room_input_participants_ready === true, requireRoomVideoInputReady: body.requireRoomVideoInputReady === true || body.require_room_video_input_ready === true, }, diff --git a/hooks/useRoom.ts b/hooks/useRoom.ts index d44922f1d..f79cf2526 100644 --- a/hooks/useRoom.ts +++ b/hooks/useRoom.ts @@ -212,7 +212,6 @@ export function useRoom(appConfig: AppConfig) { await recoverFromStartError(error); }; - setIsSessionActive(true); beginFrontendObservabilitySession(room); const dispatchAgentSession = async () => { @@ -220,6 +219,8 @@ export function useRoom(appConfig: AppConfig) { dispatchSessionId = sessionId; const signal = beginAgentSessionStart(room.name, sessionId); const dispatchPromise = requestAgentSessionDispatch(appConfig.agentName, sessionId, { + requireAgentSessionReady: usesManagedRoomInput, + requireRoomInputParticipantsReady: usesManagedRoomInput, requireRoomVideoInputReady: requiresRoomVideoInputReady(appConfig), signal, }); @@ -310,6 +311,7 @@ export function useRoom(appConfig: AppConfig) { if (!usesSandboxConcurrentStartup) { await dispatchAgentSession(); } + setIsSessionActive(true); } catch (error) { await handleStartError(error); } diff --git a/lib/session-dispatch-client.ts b/lib/session-dispatch-client.ts index 0d6b7ef1b..10812ba40 100644 --- a/lib/session-dispatch-client.ts +++ b/lib/session-dispatch-client.ts @@ -1,5 +1,7 @@ type DispatchOptions = { signal?: AbortSignal; + requireAgentSessionReady?: boolean; + requireRoomInputParticipantsReady?: boolean; requireRoomVideoInputReady?: boolean; }; @@ -27,6 +29,10 @@ export async function requestAgentSessionDispatch( body: JSON.stringify({ agentName: normalizedAgentName, sessionId: normalizedSessionId, + ...(options.requireAgentSessionReady ? { requireAgentSessionReady: true } : {}), + ...(options.requireRoomInputParticipantsReady + ? { requireRoomInputParticipantsReady: true } + : {}), ...(options.requireRoomVideoInputReady ? { requireRoomVideoInputReady: true } : {}), }), signal: options.signal, diff --git a/tests/session-dispatch-client.test.mjs b/tests/session-dispatch-client.test.mjs index 09bb1e45a..b57f06077 100644 --- a/tests/session-dispatch-client.test.mjs +++ b/tests/session-dispatch-client.test.mjs @@ -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 managed room input readiness', async () => { const originalFetch = globalThis.fetch; let postedBody; globalThis.fetch = async (_url, init) => { @@ -55,12 +55,16 @@ test('agent session dispatch can require room video input readiness', async () = const { requestAgentSessionDispatch } = await loadSessionDispatchClientModule(); await requestAgentSessionDispatch('agent-a', '11111111-2222-4333-8444-555555555555', { + requireAgentSessionReady: true, + requireRoomInputParticipantsReady: true, requireRoomVideoInputReady: true, }); assert.deepEqual(postedBody, { agentName: 'agent-a', sessionId: '11111111-2222-4333-8444-555555555555', + requireAgentSessionReady: true, + requireRoomInputParticipantsReady: true, requireRoomVideoInputReady: true, }); } finally { diff --git a/tests/session-start-dispatch.test.mjs b/tests/session-start-dispatch.test.mjs index dca58fa7e..7af9d3f38 100644 --- a/tests/session-start-dispatch.test.mjs +++ b/tests/session-start-dispatch.test.mjs @@ -177,6 +177,10 @@ test('session dispatch route only accepts anonymous LiveKit agent fallback after ); 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(routeSource, /requireRoomInputParticipantsReady/); + assert.match(routeSource, /require_room_input_participants_ready/); assert.match(readinessSource, /type AgentParticipantMatchOptions/); assert.match(readinessSource, /type ReusableAgentParticipantOptions/); assert.match(readinessSource, /allowAnonymousLiveKitAgentFallback/); @@ -211,6 +215,13 @@ test('start call dispatches the agent with a cancellable room session id', async useRoomSource, /requireRoomVideoInputReady: requiresRoomVideoInputReady\(appConfig\)/ ); + assert.match(useRoomSource, /requireAgentSessionReady: usesManagedRoomInput/); + assert.match(useRoomSource, /requireRoomInputParticipantsReady: usesManagedRoomInput/); + 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,/); }); From deda8fa7cec594dbafaa9fe6b824ada064e79bcd Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 13:18:06 +0800 Subject: [PATCH 02/12] fix: wait for configured session inputs --- app/api/session/session-dispatch-service.ts | 2 +- hooks/useRoom.ts | 10 ++++++++-- lib/input-device-config.ts | 10 ++++++++++ tests/local-dispatch-config.test.mjs | 9 +++++++++ tests/session-prewarm.test.mjs | 2 +- tests/session-start-dispatch.test.mjs | 6 +++++- 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/app/api/session/session-dispatch-service.ts b/app/api/session/session-dispatch-service.ts index 067468acc..d05b9ea5b 100644 --- a/app/api/session/session-dispatch-service.ts +++ b/app/api/session/session-dispatch-service.ts @@ -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'; diff --git a/hooks/useRoom.ts b/hooks/useRoom.ts index f79cf2526..9a5aa90fd 100644 --- a/hooks/useRoom.ts +++ b/hooks/useRoom.ts @@ -6,7 +6,10 @@ 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 { + usesBothServerRoomInputParticipants, + usesServerRoomInputDevice, +} from '@/lib/input-device-config'; import { FRONTEND_EVENTS, beginFrontendObservabilitySession, @@ -220,7 +223,10 @@ export function useRoom(appConfig: AppConfig) { const signal = beginAgentSessionStart(room.name, sessionId); const dispatchPromise = requestAgentSessionDispatch(appConfig.agentName, sessionId, { requireAgentSessionReady: usesManagedRoomInput, - requireRoomInputParticipantsReady: usesManagedRoomInput, + requireRoomInputParticipantsReady: usesBothServerRoomInputParticipants( + appConfig.audioInputDevice, + appConfig.visionInputDevice + ), requireRoomVideoInputReady: requiresRoomVideoInputReady(appConfig), signal, }); diff --git a/lib/input-device-config.ts b/lib/input-device-config.ts index 7807150fc..e89d7bd21 100644 --- a/lib/input-device-config.ts +++ b/lib/input-device-config.ts @@ -42,6 +42,16 @@ export function usesServerRoomInputDevice(inputDevice: string): boolean { return SERVER_ROOM_INPUT_DEVICES.has(inputDevice); } +export function usesBothServerRoomInputParticipants( + audioInputDevice?: string | null, + visionInputDevice?: string | null +): boolean { + return ( + usesServerRoomInputDevice(audioInputDevice || '') && + usesServerRoomInputDevice(visionInputDevice || '') + ); +} + export function resolveRoleInputDevices({ inputSource, audioInputDevice, diff --git a/tests/local-dispatch-config.test.mjs b/tests/local-dispatch-config.test.mjs index 15c9a703d..abf44fd18 100644 --- a/tests/local-dispatch-config.test.mjs +++ b/tests/local-dispatch-config.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; +const { usesBothServerRoomInputParticipants } = await import('../lib/input-device-config.ts'); + async function loadAppConfigModule() { return import('../app-config.ts'); } @@ -136,6 +138,13 @@ test('frontend resolves mixed xunfei audio with browser vision role devices', as assert.equal(config.showDefaultCameraPreview, false); }); +test('room participant readiness follows the configured server-owned inputs', () => { + assert.equal(usesBothServerRoomInputParticipants('xunfei', 'generic'), true); + assert.equal(usesBothServerRoomInputParticipants('xunfei', 'browser'), false); + assert.equal(usesBothServerRoomInputParticipants('browser', 'generic'), false); + assert.equal(usesBothServerRoomInputParticipants('browser', 'browser'), false); +}); + test('frontend normalizes invalid mixed output devices to the base role input device', async () => { const { resolveInputDeviceConfig } = await loadAppConfigModule(); diff --git a/tests/session-prewarm.test.mjs b/tests/session-prewarm.test.mjs index 9a44fdb32..1eff3fc6b 100644 --- a/tests/session-prewarm.test.mjs +++ b/tests/session-prewarm.test.mjs @@ -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( diff --git a/tests/session-start-dispatch.test.mjs b/tests/session-start-dispatch.test.mjs index 7af9d3f38..35a87e0b1 100644 --- a/tests/session-start-dispatch.test.mjs +++ b/tests/session-start-dispatch.test.mjs @@ -216,7 +216,11 @@ test('start call dispatches the agent with a cancellable room session id', async /requireRoomVideoInputReady: requiresRoomVideoInputReady\(appConfig\)/ ); assert.match(useRoomSource, /requireAgentSessionReady: usesManagedRoomInput/); - assert.match(useRoomSource, /requireRoomInputParticipantsReady: usesManagedRoomInput/); + assert.match( + useRoomSource, + /requireRoomInputParticipantsReady: usesBothServerRoomInputParticipants\(/ + ); + assert.match(useRoomSource, /await Promise\.allSettled\(\[/); assert.ok( useRoomSource.lastIndexOf('setIsSessionActive(true)') > useRoomSource.lastIndexOf('await dispatchAgentSession()'), From 50c624ce5a4f2b545917339d202ee7771f9b59da Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 13:22:38 +0800 Subject: [PATCH 03/12] fix: use agent session readiness as startup gate --- app/api/session/dispatch/route.ts | 5 ----- hooks/useRoom.ts | 9 +-------- lib/input-device-config.ts | 10 ---------- lib/session-dispatch-client.ts | 4 ---- tests/local-dispatch-config.test.mjs | 9 --------- tests/session-dispatch-client.test.mjs | 4 +--- tests/session-start-dispatch.test.mjs | 6 ------ 7 files changed, 2 insertions(+), 45 deletions(-) diff --git a/app/api/session/dispatch/route.ts b/app/api/session/dispatch/route.ts index a3f51ae5b..641fb02ad 100644 --- a/app/api/session/dispatch/route.ts +++ b/app/api/session/dispatch/route.ts @@ -22,8 +22,6 @@ export async function POST(req: Request) { session_id?: string; requireAgentSessionReady?: boolean; require_agent_session_ready?: boolean; - requireRoomInputParticipantsReady?: boolean; - require_room_input_participants_ready?: boolean; requireRoomVideoInputReady?: boolean; require_room_video_input_ready?: boolean; }; @@ -63,9 +61,6 @@ export async function POST(req: Request) { readiness: { requireAgentSessionReady: body.requireAgentSessionReady === true || body.require_agent_session_ready === true, - requireRoomInputParticipantsReady: - body.requireRoomInputParticipantsReady === true || - body.require_room_input_participants_ready === true, requireRoomVideoInputReady: body.requireRoomVideoInputReady === true || body.require_room_video_input_ready === true, }, diff --git a/hooks/useRoom.ts b/hooks/useRoom.ts index 9a5aa90fd..1206c8c5a 100644 --- a/hooks/useRoom.ts +++ b/hooks/useRoom.ts @@ -6,10 +6,7 @@ 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 { - usesBothServerRoomInputParticipants, - usesServerRoomInputDevice, -} from '@/lib/input-device-config'; +import { usesServerRoomInputDevice } from '@/lib/input-device-config'; import { FRONTEND_EVENTS, beginFrontendObservabilitySession, @@ -223,10 +220,6 @@ export function useRoom(appConfig: AppConfig) { const signal = beginAgentSessionStart(room.name, sessionId); const dispatchPromise = requestAgentSessionDispatch(appConfig.agentName, sessionId, { requireAgentSessionReady: usesManagedRoomInput, - requireRoomInputParticipantsReady: usesBothServerRoomInputParticipants( - appConfig.audioInputDevice, - appConfig.visionInputDevice - ), requireRoomVideoInputReady: requiresRoomVideoInputReady(appConfig), signal, }); diff --git a/lib/input-device-config.ts b/lib/input-device-config.ts index e89d7bd21..7807150fc 100644 --- a/lib/input-device-config.ts +++ b/lib/input-device-config.ts @@ -42,16 +42,6 @@ export function usesServerRoomInputDevice(inputDevice: string): boolean { return SERVER_ROOM_INPUT_DEVICES.has(inputDevice); } -export function usesBothServerRoomInputParticipants( - audioInputDevice?: string | null, - visionInputDevice?: string | null -): boolean { - return ( - usesServerRoomInputDevice(audioInputDevice || '') && - usesServerRoomInputDevice(visionInputDevice || '') - ); -} - export function resolveRoleInputDevices({ inputSource, audioInputDevice, diff --git a/lib/session-dispatch-client.ts b/lib/session-dispatch-client.ts index 10812ba40..1635966dc 100644 --- a/lib/session-dispatch-client.ts +++ b/lib/session-dispatch-client.ts @@ -1,7 +1,6 @@ type DispatchOptions = { signal?: AbortSignal; requireAgentSessionReady?: boolean; - requireRoomInputParticipantsReady?: boolean; requireRoomVideoInputReady?: boolean; }; @@ -30,9 +29,6 @@ export async function requestAgentSessionDispatch( agentName: normalizedAgentName, sessionId: normalizedSessionId, ...(options.requireAgentSessionReady ? { requireAgentSessionReady: true } : {}), - ...(options.requireRoomInputParticipantsReady - ? { requireRoomInputParticipantsReady: true } - : {}), ...(options.requireRoomVideoInputReady ? { requireRoomVideoInputReady: true } : {}), }), signal: options.signal, diff --git a/tests/local-dispatch-config.test.mjs b/tests/local-dispatch-config.test.mjs index abf44fd18..15c9a703d 100644 --- a/tests/local-dispatch-config.test.mjs +++ b/tests/local-dispatch-config.test.mjs @@ -2,8 +2,6 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; -const { usesBothServerRoomInputParticipants } = await import('../lib/input-device-config.ts'); - async function loadAppConfigModule() { return import('../app-config.ts'); } @@ -138,13 +136,6 @@ test('frontend resolves mixed xunfei audio with browser vision role devices', as assert.equal(config.showDefaultCameraPreview, false); }); -test('room participant readiness follows the configured server-owned inputs', () => { - assert.equal(usesBothServerRoomInputParticipants('xunfei', 'generic'), true); - assert.equal(usesBothServerRoomInputParticipants('xunfei', 'browser'), false); - assert.equal(usesBothServerRoomInputParticipants('browser', 'generic'), false); - assert.equal(usesBothServerRoomInputParticipants('browser', 'browser'), false); -}); - test('frontend normalizes invalid mixed output devices to the base role input device', async () => { const { resolveInputDeviceConfig } = await loadAppConfigModule(); diff --git a/tests/session-dispatch-client.test.mjs b/tests/session-dispatch-client.test.mjs index b57f06077..375aa80cb 100644 --- a/tests/session-dispatch-client.test.mjs +++ b/tests/session-dispatch-client.test.mjs @@ -43,7 +43,7 @@ test('agent session dispatch sends only canonical session id to Next API', async } }); -test('agent session dispatch can require managed room input readiness', async () => { +test('agent session dispatch can require authoritative session readiness', async () => { const originalFetch = globalThis.fetch; let postedBody; globalThis.fetch = async (_url, init) => { @@ -56,7 +56,6 @@ test('agent session dispatch can require managed room input readiness', async () await requestAgentSessionDispatch('agent-a', '11111111-2222-4333-8444-555555555555', { requireAgentSessionReady: true, - requireRoomInputParticipantsReady: true, requireRoomVideoInputReady: true, }); @@ -64,7 +63,6 @@ test('agent session dispatch can require managed room input readiness', async () agentName: 'agent-a', sessionId: '11111111-2222-4333-8444-555555555555', requireAgentSessionReady: true, - requireRoomInputParticipantsReady: true, requireRoomVideoInputReady: true, }); } finally { diff --git a/tests/session-start-dispatch.test.mjs b/tests/session-start-dispatch.test.mjs index 35a87e0b1..404e665bc 100644 --- a/tests/session-start-dispatch.test.mjs +++ b/tests/session-start-dispatch.test.mjs @@ -179,8 +179,6 @@ test('session dispatch route only accepts anonymous LiveKit agent fallback after assert.match(routeSource, /require_room_video_input_ready/); assert.match(routeSource, /requireAgentSessionReady/); assert.match(routeSource, /require_agent_session_ready/); - assert.match(routeSource, /requireRoomInputParticipantsReady/); - assert.match(routeSource, /require_room_input_participants_ready/); assert.match(readinessSource, /type AgentParticipantMatchOptions/); assert.match(readinessSource, /type ReusableAgentParticipantOptions/); assert.match(readinessSource, /allowAnonymousLiveKitAgentFallback/); @@ -216,10 +214,6 @@ test('start call dispatches the agent with a cancellable room session id', async /requireRoomVideoInputReady: requiresRoomVideoInputReady\(appConfig\)/ ); assert.match(useRoomSource, /requireAgentSessionReady: usesManagedRoomInput/); - assert.match( - useRoomSource, - /requireRoomInputParticipantsReady: usesBothServerRoomInputParticipants\(/ - ); assert.match(useRoomSource, /await Promise\.allSettled\(\[/); assert.ok( useRoomSource.lastIndexOf('setIsSessionActive(true)') > From 18db439cd6569fa4bf7a7e6571eefe8fe89aa9d5 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 13:24:57 +0800 Subject: [PATCH 04/12] fix: keep video readiness out of voice startup --- app/api/session/dispatch/route.ts | 4 ---- hooks/useRoom.ts | 8 -------- lib/session-dispatch-client.ts | 2 -- tests/session-dispatch-client.test.mjs | 2 -- tests/session-start-dispatch.test.mjs | 6 ------ 5 files changed, 22 deletions(-) diff --git a/app/api/session/dispatch/route.ts b/app/api/session/dispatch/route.ts index 641fb02ad..59661c172 100644 --- a/app/api/session/dispatch/route.ts +++ b/app/api/session/dispatch/route.ts @@ -22,8 +22,6 @@ export async function POST(req: Request) { session_id?: string; requireAgentSessionReady?: boolean; require_agent_session_ready?: boolean; - requireRoomVideoInputReady?: boolean; - require_room_video_input_ready?: boolean; }; try { body = await req.json(); @@ -61,8 +59,6 @@ export async function POST(req: Request) { readiness: { requireAgentSessionReady: body.requireAgentSessionReady === true || body.require_agent_session_ready === true, - requireRoomVideoInputReady: - body.requireRoomVideoInputReady === true || body.require_room_video_input_ready === true, }, }); return NextResponse.json({ status: 'dispatched', roomName, agentName, sessionId, dispatch }); diff --git a/hooks/useRoom.ts b/hooks/useRoom.ts index 1206c8c5a..25e023a88 100644 --- a/hooks/useRoom.ts +++ b/hooks/useRoom.ts @@ -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, @@ -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(null); @@ -220,7 +213,6 @@ export function useRoom(appConfig: AppConfig) { const signal = beginAgentSessionStart(room.name, sessionId); const dispatchPromise = requestAgentSessionDispatch(appConfig.agentName, sessionId, { requireAgentSessionReady: usesManagedRoomInput, - requireRoomVideoInputReady: requiresRoomVideoInputReady(appConfig), signal, }); registerAgentSessionDispatch(room.name, sessionId, dispatchPromise); diff --git a/lib/session-dispatch-client.ts b/lib/session-dispatch-client.ts index 1635966dc..06fa7c93d 100644 --- a/lib/session-dispatch-client.ts +++ b/lib/session-dispatch-client.ts @@ -1,7 +1,6 @@ type DispatchOptions = { signal?: AbortSignal; requireAgentSessionReady?: boolean; - requireRoomVideoInputReady?: boolean; }; export class AgentSessionDispatchCancelledError extends Error { @@ -29,7 +28,6 @@ export async function requestAgentSessionDispatch( agentName: normalizedAgentName, sessionId: normalizedSessionId, ...(options.requireAgentSessionReady ? { requireAgentSessionReady: true } : {}), - ...(options.requireRoomVideoInputReady ? { requireRoomVideoInputReady: true } : {}), }), signal: options.signal, }); diff --git a/tests/session-dispatch-client.test.mjs b/tests/session-dispatch-client.test.mjs index 375aa80cb..3e0c9a8c2 100644 --- a/tests/session-dispatch-client.test.mjs +++ b/tests/session-dispatch-client.test.mjs @@ -56,14 +56,12 @@ test('agent session dispatch can require authoritative session readiness', async await requestAgentSessionDispatch('agent-a', '11111111-2222-4333-8444-555555555555', { requireAgentSessionReady: true, - requireRoomVideoInputReady: true, }); assert.deepEqual(postedBody, { agentName: 'agent-a', sessionId: '11111111-2222-4333-8444-555555555555', requireAgentSessionReady: true, - requireRoomVideoInputReady: true, }); } finally { globalThis.fetch = originalFetch; diff --git a/tests/session-start-dispatch.test.mjs b/tests/session-start-dispatch.test.mjs index 404e665bc..eba6d783f 100644 --- a/tests/session-start-dispatch.test.mjs +++ b/tests/session-start-dispatch.test.mjs @@ -175,8 +175,6 @@ 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/); @@ -209,10 +207,6 @@ 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( From 5ac7bad3de9d8bee0abcf35de4e238ba76a8e2e2 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 13:29:21 +0800 Subject: [PATCH 05/12] fix: recognize server serialized readiness attributes --- lib/session-dispatch-readiness.ts | 7 ++++++- tests/session-dispatch-readiness.test.mjs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/session-dispatch-readiness.ts b/lib/session-dispatch-readiness.ts index 2c6026cc3..0b55e95b1 100644 --- a/lib/session-dispatch-readiness.ts +++ b/lib/session-dispatch-readiness.ts @@ -16,6 +16,7 @@ export type ReusableAgentParticipantOptions = AgentParticipantMatchOptions & { }; export const AGENT_SESSION_READY_ATTRIBUTE = 'liveavatar.agent.session_ready'; +const AGENT_SESSION_READY_ATTRIBUTE_CAMEL = 'liveavatarAgentSessionReady'; const ROOM_AUDIO_INPUT_IDENTITY = 'room_audio_input'; const ROOM_VIDEO_INPUT_IDENTITY = 'room_video_input'; @@ -127,7 +128,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) { diff --git a/tests/session-dispatch-readiness.test.mjs b/tests/session-dispatch-readiness.test.mjs index 7977a5d74..93567c1d6 100644 --- a/tests/session-dispatch-readiness.test.mjs +++ b/tests/session-dispatch-readiness.test.mjs @@ -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', From 5e4c87c1c4cd3137325e1f5b73f04b7e57d13ef4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 13:57:28 +0800 Subject: [PATCH 06/12] fix: gate prewarm on authoritative session readiness --- app/api/session/session-dispatch-service.ts | 1 - lib/session-dispatch-readiness.ts | 1 + tests/session-prewarm.test.mjs | 6 +++--- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/session/session-dispatch-service.ts b/app/api/session/session-dispatch-service.ts index d05b9ea5b..44075ff1d 100644 --- a/app/api/session/session-dispatch-service.ts +++ b/app/api/session/session-dispatch-service.ts @@ -302,7 +302,6 @@ export async function prewarmRoomSession( ...request, readiness: { requireAgentSessionReady: true, - requireRoomInputParticipantsReady: true, }, }, { diff --git a/lib/session-dispatch-readiness.ts b/lib/session-dispatch-readiness.ts index 0b55e95b1..51cb58e47 100644 --- a/lib/session-dispatch-readiness.ts +++ b/lib/session-dispatch-readiness.ts @@ -16,6 +16,7 @@ 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'; diff --git a/tests/session-prewarm.test.mjs b/tests/session-prewarm.test.mjs index 1eff3fc6b..243ac835e 100644 --- a/tests/session-prewarm.test.mjs +++ b/tests/session-prewarm.test.mjs @@ -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; @@ -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; @@ -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, }); }); From 8f64cf6cf98560e8c4f41ad0d7acb13a53cecb87 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 19:17:01 +0800 Subject: [PATCH 07/12] fix: preserve tool preambles in chat history --- hooks/useChatMessages.ts | 12 +++++++--- lib/transcription-history.ts | 23 ++++++++++++++++++++ tests/chat-message-filter.test.mjs | 35 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 lib/transcription-history.ts diff --git a/hooks/useChatMessages.ts b/hooks/useChatMessages.ts index e9b75f5d0..ed6be0d0a 100644 --- a/hooks/useChatMessages.ts +++ b/hooks/useChatMessages.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Room } from 'livekit-client'; import { type ReceivedChatMessage, @@ -9,6 +9,7 @@ import { } from '@livekit/components-react'; import { type AppConfig } from '@/app-config'; import { isRenderableChatMessage } from '@/lib/chat-message-filter'; +import { mergeTranscriptionHistory } from '@/lib/transcription-history'; function transcriptionToChatMessage( textStream: TextStreamData, @@ -76,10 +77,15 @@ export function useChatMessages( const chat = useChat(); const room = useRoomContext(); const transcriptions: TextStreamData[] = useTranscriptions(); + const [transcriptionHistory, setTranscriptionHistory] = useState([]); + + useEffect(() => { + setTranscriptionHistory((previous) => mergeTranscriptionHistory(previous, transcriptions)); + }, [transcriptions]); const mergedTranscriptions = useMemo(() => { // 处理转录消息 - const transcriptionMessages = transcriptions.map((transcription) => + const transcriptionMessages = transcriptionHistory.map((transcription) => transcriptionToChatMessage(transcription, room, config) ); @@ -112,7 +118,7 @@ export function useChatMessages( ].filter(isRenderableChatMessage); return merged.sort((a, b) => a.timestamp - b.timestamp); - }, [transcriptions, chat.chatMessages, room, config]); + }, [transcriptionHistory, chat.chatMessages, room, config]); return mergedTranscriptions; } diff --git a/lib/transcription-history.ts b/lib/transcription-history.ts new file mode 100644 index 000000000..fce673cf9 --- /dev/null +++ b/lib/transcription-history.ts @@ -0,0 +1,23 @@ +import { type TextStreamData } from '@livekit/components-react'; + +const DEFAULT_TRANSCRIPTION_HISTORY_SIZE = 100; + +/** + * Keep completed text streams even when LiveKit replaces the current entry for + * the same speech segment. A tool preamble and its final answer can share one + * segment id while still arriving as distinct text streams. + */ +export function mergeTranscriptionHistory( + previous: TextStreamData[], + current: TextStreamData[], + maxEntries = DEFAULT_TRANSCRIPTION_HISTORY_SIZE +): TextStreamData[] { + if (current.length === 0) return []; + + const byStreamId = new Map(previous.map((entry) => [entry.streamInfo.id, entry])); + current.forEach((entry) => byStreamId.set(entry.streamInfo.id, entry)); + + return Array.from(byStreamId.values()) + .sort((a, b) => a.streamInfo.timestamp - b.streamInfo.timestamp) + .slice(-maxEntries); +} diff --git a/tests/chat-message-filter.test.mjs b/tests/chat-message-filter.test.mjs index 9f3074b99..35dd6b4fa 100644 --- a/tests/chat-message-filter.test.mjs +++ b/tests/chat-message-filter.test.mjs @@ -3,6 +3,19 @@ import { readFile } from 'node:fs/promises'; 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) { + return { + text, + participantInfo: { identity: 'frontdesk-agent' }, + streamInfo: { + id, + timestamp, + attributes: { 'lk.segment_id': segmentId }, + }, + }; +} test('chat message filter hides empty transcription messages', () => { assert.equal(isRenderableChatMessage({ message: '' }), false); @@ -19,3 +32,25 @@ test('chat message hook filters empty merged messages before sorting', async () assert.match(source, /isRenderableChatMessage/); assert.match(source, /\.filter\(isRenderableChatMessage\)/); }); + +test('transcription history preserves a tool preamble replaced by the final stream', () => { + const preamble = transcription('stream-preamble', 'speech-1', 100, '我查一下。'); + const final = transcription('stream-final', 'speech-1', 200, '已经设置好了。'); + + const history = mergeTranscriptionHistory([preamble], [final]); + + assert.deepEqual( + history.map(({ text }) => text), + ['我查一下。', '已经设置好了。'] + ); +}); + +test('transcription history updates partial text without duplicating one stream', () => { + const partial = transcription('stream-1', 'speech-1', 100, '我查'); + const completed = transcription('stream-1', 'speech-1', 100, '我查一下。'); + + const history = mergeTranscriptionHistory([partial], [completed]); + + assert.equal(history.length, 1); + assert.equal(history[0].text, '我查一下。'); +}); From 1197d4a58aaf2dfa1f7fc3076fd066ef45f6724b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 19:23:16 +0800 Subject: [PATCH 08/12] fix: retain transcription history across reconnects --- hooks/useChatMessages.ts | 4 ++++ lib/transcription-history.ts | 2 +- tests/chat-message-filter.test.mjs | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/hooks/useChatMessages.ts b/hooks/useChatMessages.ts index ed6be0d0a..7125e826d 100644 --- a/hooks/useChatMessages.ts +++ b/hooks/useChatMessages.ts @@ -79,6 +79,10 @@ export function useChatMessages( const transcriptions: TextStreamData[] = useTranscriptions(); const [transcriptionHistory, setTranscriptionHistory] = useState([]); + useEffect(() => { + setTranscriptionHistory([]); + }, [room.name]); + useEffect(() => { setTranscriptionHistory((previous) => mergeTranscriptionHistory(previous, transcriptions)); }, [transcriptions]); diff --git a/lib/transcription-history.ts b/lib/transcription-history.ts index fce673cf9..2a16a8da8 100644 --- a/lib/transcription-history.ts +++ b/lib/transcription-history.ts @@ -12,7 +12,7 @@ export function mergeTranscriptionHistory( current: TextStreamData[], maxEntries = DEFAULT_TRANSCRIPTION_HISTORY_SIZE ): TextStreamData[] { - if (current.length === 0) return []; + 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)); diff --git a/tests/chat-message-filter.test.mjs b/tests/chat-message-filter.test.mjs index 35dd6b4fa..632df3fcd 100644 --- a/tests/chat-message-filter.test.mjs +++ b/tests/chat-message-filter.test.mjs @@ -54,3 +54,11 @@ test('transcription history updates partial text without duplicating one stream' assert.equal(history.length, 1); assert.equal(history[0].text, '我查一下。'); }); + +test('transcription history survives a transient empty snapshot', () => { + const preamble = transcription('stream-1', 'speech-1', 100, '我查一下。'); + + const history = mergeTranscriptionHistory([preamble], []); + + assert.deepEqual(history, [preamble]); +}); From 40a51d5c514d11827311dc48eca86d05624d3708 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 19:53:39 +0800 Subject: [PATCH 09/12] Revert "fix: retain transcription history across reconnects" This reverts commit 1197d4a58aaf2dfa1f7fc3076fd066ef45f6724b. --- hooks/useChatMessages.ts | 4 ---- lib/transcription-history.ts | 2 +- tests/chat-message-filter.test.mjs | 8 -------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/hooks/useChatMessages.ts b/hooks/useChatMessages.ts index 7125e826d..ed6be0d0a 100644 --- a/hooks/useChatMessages.ts +++ b/hooks/useChatMessages.ts @@ -79,10 +79,6 @@ export function useChatMessages( const transcriptions: TextStreamData[] = useTranscriptions(); const [transcriptionHistory, setTranscriptionHistory] = useState([]); - useEffect(() => { - setTranscriptionHistory([]); - }, [room.name]); - useEffect(() => { setTranscriptionHistory((previous) => mergeTranscriptionHistory(previous, transcriptions)); }, [transcriptions]); diff --git a/lib/transcription-history.ts b/lib/transcription-history.ts index 2a16a8da8..fce673cf9 100644 --- a/lib/transcription-history.ts +++ b/lib/transcription-history.ts @@ -12,7 +12,7 @@ export function mergeTranscriptionHistory( current: TextStreamData[], maxEntries = DEFAULT_TRANSCRIPTION_HISTORY_SIZE ): TextStreamData[] { - if (current.length === 0) return previous; + if (current.length === 0) return []; const byStreamId = new Map(previous.map((entry) => [entry.streamInfo.id, entry])); current.forEach((entry) => byStreamId.set(entry.streamInfo.id, entry)); diff --git a/tests/chat-message-filter.test.mjs b/tests/chat-message-filter.test.mjs index 632df3fcd..35dd6b4fa 100644 --- a/tests/chat-message-filter.test.mjs +++ b/tests/chat-message-filter.test.mjs @@ -54,11 +54,3 @@ test('transcription history updates partial text without duplicating one stream' assert.equal(history.length, 1); assert.equal(history[0].text, '我查一下。'); }); - -test('transcription history survives a transient empty snapshot', () => { - const preamble = transcription('stream-1', 'speech-1', 100, '我查一下。'); - - const history = mergeTranscriptionHistory([preamble], []); - - assert.deepEqual(history, [preamble]); -}); From aec6ac6d7719ce023c6b3c5e590f69b714db7bb9 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 19:53:39 +0800 Subject: [PATCH 10/12] Revert "fix: preserve tool preambles in chat history" This reverts commit 8f64cf6cf98560e8c4f41ad0d7acb13a53cecb87. --- hooks/useChatMessages.ts | 12 +++------- lib/transcription-history.ts | 23 -------------------- tests/chat-message-filter.test.mjs | 35 ------------------------------ 3 files changed, 3 insertions(+), 67 deletions(-) delete mode 100644 lib/transcription-history.ts diff --git a/hooks/useChatMessages.ts b/hooks/useChatMessages.ts index ed6be0d0a..e9b75f5d0 100644 --- a/hooks/useChatMessages.ts +++ b/hooks/useChatMessages.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Room } from 'livekit-client'; import { type ReceivedChatMessage, @@ -9,7 +9,6 @@ import { } from '@livekit/components-react'; import { type AppConfig } from '@/app-config'; import { isRenderableChatMessage } from '@/lib/chat-message-filter'; -import { mergeTranscriptionHistory } from '@/lib/transcription-history'; function transcriptionToChatMessage( textStream: TextStreamData, @@ -77,15 +76,10 @@ export function useChatMessages( const chat = useChat(); const room = useRoomContext(); const transcriptions: TextStreamData[] = useTranscriptions(); - const [transcriptionHistory, setTranscriptionHistory] = useState([]); - - useEffect(() => { - setTranscriptionHistory((previous) => mergeTranscriptionHistory(previous, transcriptions)); - }, [transcriptions]); const mergedTranscriptions = useMemo(() => { // 处理转录消息 - const transcriptionMessages = transcriptionHistory.map((transcription) => + const transcriptionMessages = transcriptions.map((transcription) => transcriptionToChatMessage(transcription, room, config) ); @@ -118,7 +112,7 @@ export function useChatMessages( ].filter(isRenderableChatMessage); return merged.sort((a, b) => a.timestamp - b.timestamp); - }, [transcriptionHistory, chat.chatMessages, room, config]); + }, [transcriptions, chat.chatMessages, room, config]); return mergedTranscriptions; } diff --git a/lib/transcription-history.ts b/lib/transcription-history.ts deleted file mode 100644 index fce673cf9..000000000 --- a/lib/transcription-history.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { type TextStreamData } from '@livekit/components-react'; - -const DEFAULT_TRANSCRIPTION_HISTORY_SIZE = 100; - -/** - * Keep completed text streams even when LiveKit replaces the current entry for - * the same speech segment. A tool preamble and its final answer can share one - * segment id while still arriving as distinct text streams. - */ -export function mergeTranscriptionHistory( - previous: TextStreamData[], - current: TextStreamData[], - maxEntries = DEFAULT_TRANSCRIPTION_HISTORY_SIZE -): TextStreamData[] { - if (current.length === 0) return []; - - const byStreamId = new Map(previous.map((entry) => [entry.streamInfo.id, entry])); - current.forEach((entry) => byStreamId.set(entry.streamInfo.id, entry)); - - return Array.from(byStreamId.values()) - .sort((a, b) => a.streamInfo.timestamp - b.streamInfo.timestamp) - .slice(-maxEntries); -} diff --git a/tests/chat-message-filter.test.mjs b/tests/chat-message-filter.test.mjs index 35dd6b4fa..9f3074b99 100644 --- a/tests/chat-message-filter.test.mjs +++ b/tests/chat-message-filter.test.mjs @@ -3,19 +3,6 @@ import { readFile } from 'node:fs/promises'; 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) { - return { - text, - participantInfo: { identity: 'frontdesk-agent' }, - streamInfo: { - id, - timestamp, - attributes: { 'lk.segment_id': segmentId }, - }, - }; -} test('chat message filter hides empty transcription messages', () => { assert.equal(isRenderableChatMessage({ message: '' }), false); @@ -32,25 +19,3 @@ test('chat message hook filters empty merged messages before sorting', async () assert.match(source, /isRenderableChatMessage/); assert.match(source, /\.filter\(isRenderableChatMessage\)/); }); - -test('transcription history preserves a tool preamble replaced by the final stream', () => { - const preamble = transcription('stream-preamble', 'speech-1', 100, '我查一下。'); - const final = transcription('stream-final', 'speech-1', 200, '已经设置好了。'); - - const history = mergeTranscriptionHistory([preamble], [final]); - - assert.deepEqual( - history.map(({ text }) => text), - ['我查一下。', '已经设置好了。'] - ); -}); - -test('transcription history updates partial text without duplicating one stream', () => { - const partial = transcription('stream-1', 'speech-1', 100, '我查'); - const completed = transcription('stream-1', 'speech-1', 100, '我查一下。'); - - const history = mergeTranscriptionHistory([partial], [completed]); - - assert.equal(history.length, 1); - assert.equal(history[0].text, '我查一下。'); -}); From 5fe07cf36aec2b82e16cc8aab43560bf6962ddb6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 19:56:53 +0800 Subject: [PATCH 11/12] Revert "Revert "fix: preserve tool preambles in chat history"" This reverts commit aec6ac6d7719ce023c6b3c5e590f69b714db7bb9. --- hooks/useChatMessages.ts | 12 +++++++--- lib/transcription-history.ts | 23 ++++++++++++++++++++ tests/chat-message-filter.test.mjs | 35 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 lib/transcription-history.ts diff --git a/hooks/useChatMessages.ts b/hooks/useChatMessages.ts index e9b75f5d0..ed6be0d0a 100644 --- a/hooks/useChatMessages.ts +++ b/hooks/useChatMessages.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Room } from 'livekit-client'; import { type ReceivedChatMessage, @@ -9,6 +9,7 @@ import { } from '@livekit/components-react'; import { type AppConfig } from '@/app-config'; import { isRenderableChatMessage } from '@/lib/chat-message-filter'; +import { mergeTranscriptionHistory } from '@/lib/transcription-history'; function transcriptionToChatMessage( textStream: TextStreamData, @@ -76,10 +77,15 @@ export function useChatMessages( const chat = useChat(); const room = useRoomContext(); const transcriptions: TextStreamData[] = useTranscriptions(); + const [transcriptionHistory, setTranscriptionHistory] = useState([]); + + useEffect(() => { + setTranscriptionHistory((previous) => mergeTranscriptionHistory(previous, transcriptions)); + }, [transcriptions]); const mergedTranscriptions = useMemo(() => { // 处理转录消息 - const transcriptionMessages = transcriptions.map((transcription) => + const transcriptionMessages = transcriptionHistory.map((transcription) => transcriptionToChatMessage(transcription, room, config) ); @@ -112,7 +118,7 @@ export function useChatMessages( ].filter(isRenderableChatMessage); return merged.sort((a, b) => a.timestamp - b.timestamp); - }, [transcriptions, chat.chatMessages, room, config]); + }, [transcriptionHistory, chat.chatMessages, room, config]); return mergedTranscriptions; } diff --git a/lib/transcription-history.ts b/lib/transcription-history.ts new file mode 100644 index 000000000..fce673cf9 --- /dev/null +++ b/lib/transcription-history.ts @@ -0,0 +1,23 @@ +import { type TextStreamData } from '@livekit/components-react'; + +const DEFAULT_TRANSCRIPTION_HISTORY_SIZE = 100; + +/** + * Keep completed text streams even when LiveKit replaces the current entry for + * the same speech segment. A tool preamble and its final answer can share one + * segment id while still arriving as distinct text streams. + */ +export function mergeTranscriptionHistory( + previous: TextStreamData[], + current: TextStreamData[], + maxEntries = DEFAULT_TRANSCRIPTION_HISTORY_SIZE +): TextStreamData[] { + if (current.length === 0) return []; + + const byStreamId = new Map(previous.map((entry) => [entry.streamInfo.id, entry])); + current.forEach((entry) => byStreamId.set(entry.streamInfo.id, entry)); + + return Array.from(byStreamId.values()) + .sort((a, b) => a.streamInfo.timestamp - b.streamInfo.timestamp) + .slice(-maxEntries); +} diff --git a/tests/chat-message-filter.test.mjs b/tests/chat-message-filter.test.mjs index 9f3074b99..35dd6b4fa 100644 --- a/tests/chat-message-filter.test.mjs +++ b/tests/chat-message-filter.test.mjs @@ -3,6 +3,19 @@ import { readFile } from 'node:fs/promises'; 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) { + return { + text, + participantInfo: { identity: 'frontdesk-agent' }, + streamInfo: { + id, + timestamp, + attributes: { 'lk.segment_id': segmentId }, + }, + }; +} test('chat message filter hides empty transcription messages', () => { assert.equal(isRenderableChatMessage({ message: '' }), false); @@ -19,3 +32,25 @@ test('chat message hook filters empty merged messages before sorting', async () assert.match(source, /isRenderableChatMessage/); assert.match(source, /\.filter\(isRenderableChatMessage\)/); }); + +test('transcription history preserves a tool preamble replaced by the final stream', () => { + const preamble = transcription('stream-preamble', 'speech-1', 100, '我查一下。'); + const final = transcription('stream-final', 'speech-1', 200, '已经设置好了。'); + + const history = mergeTranscriptionHistory([preamble], [final]); + + assert.deepEqual( + history.map(({ text }) => text), + ['我查一下。', '已经设置好了。'] + ); +}); + +test('transcription history updates partial text without duplicating one stream', () => { + const partial = transcription('stream-1', 'speech-1', 100, '我查'); + const completed = transcription('stream-1', 'speech-1', 100, '我查一下。'); + + const history = mergeTranscriptionHistory([partial], [completed]); + + assert.equal(history.length, 1); + assert.equal(history[0].text, '我查一下。'); +}); From fed9950ed8d732924c2deffd001106e9b864b4f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 19:56:53 +0800 Subject: [PATCH 12/12] Revert "Revert "fix: retain transcription history across reconnects"" This reverts commit 40a51d5c514d11827311dc48eca86d05624d3708. --- hooks/useChatMessages.ts | 4 ++++ lib/transcription-history.ts | 2 +- tests/chat-message-filter.test.mjs | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/hooks/useChatMessages.ts b/hooks/useChatMessages.ts index ed6be0d0a..7125e826d 100644 --- a/hooks/useChatMessages.ts +++ b/hooks/useChatMessages.ts @@ -79,6 +79,10 @@ export function useChatMessages( const transcriptions: TextStreamData[] = useTranscriptions(); const [transcriptionHistory, setTranscriptionHistory] = useState([]); + useEffect(() => { + setTranscriptionHistory([]); + }, [room.name]); + useEffect(() => { setTranscriptionHistory((previous) => mergeTranscriptionHistory(previous, transcriptions)); }, [transcriptions]); diff --git a/lib/transcription-history.ts b/lib/transcription-history.ts index fce673cf9..2a16a8da8 100644 --- a/lib/transcription-history.ts +++ b/lib/transcription-history.ts @@ -12,7 +12,7 @@ export function mergeTranscriptionHistory( current: TextStreamData[], maxEntries = DEFAULT_TRANSCRIPTION_HISTORY_SIZE ): TextStreamData[] { - if (current.length === 0) return []; + 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)); diff --git a/tests/chat-message-filter.test.mjs b/tests/chat-message-filter.test.mjs index 35dd6b4fa..632df3fcd 100644 --- a/tests/chat-message-filter.test.mjs +++ b/tests/chat-message-filter.test.mjs @@ -54,3 +54,11 @@ test('transcription history updates partial text without duplicating one stream' assert.equal(history.length, 1); assert.equal(history[0].text, '我查一下。'); }); + +test('transcription history survives a transient empty snapshot', () => { + const preamble = transcription('stream-1', 'speech-1', 100, '我查一下。'); + + const history = mergeTranscriptionHistory([preamble], []); + + assert.deepEqual(history, [preamble]); +});