diff --git a/.changeset/pause-resume-conversation.md b/.changeset/pause-resume-conversation.md new file mode 100644 index 000000000..8cdb0c727 --- /dev/null +++ b/.changeset/pause-resume-conversation.md @@ -0,0 +1,6 @@ +--- +"@elevenlabs/client": patch +"@elevenlabs/react": patch +--- + +Add pause and resume controls with paused state for active voice conversations. diff --git a/packages/client/src/BaseConversation.test.ts b/packages/client/src/BaseConversation.test.ts index c8b493a3c..8504138a9 100644 --- a/packages/client/src/BaseConversation.test.ts +++ b/packages/client/src/BaseConversation.test.ts @@ -16,24 +16,48 @@ const noopConnection = { sendMessage: () => {}, } as unknown as BaseConnection; +function createConnection(overrides: Partial = {}) { + return { + ...noopConnection, + ...overrides, + } as unknown as BaseConnection; +} + class TestConversation extends BaseConversation { + public pauseCount = 0; + public resumeCount = 0; + public static getFullOptions(partialOptions: PartialOptions): Options { return super.getFullOptions(partialOptions); } - public static create(options: { origin?: string } = {}): TestConversation { + public static create( + options: { origin?: string } = {}, + connection = noopConnection + ): TestConversation { const fullOptions = TestConversation.getFullOptions({ agentId: "test-agent-id", connectionType: "webrtc", ...options, }); - return new TestConversation(fullOptions, noopConnection); + return new TestConversation(fullOptions, connection); } constructor(options: Options, connection: BaseConnection) { super(options, connection); } + protected override async handlePause(): Promise<() => Promise> { + this.pauseCount++; + return async () => { + this.resumeCount++; + }; + } + + protected override shouldHandleAudio(): boolean { + return true; + } + public setVolume(): void {} public setMicMuted(): void {} public getInputByteFrequencyData(): Uint8Array { @@ -100,6 +124,84 @@ describe("BaseConversation", () => { ); }); + describe("pause and resume", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("reports pause state", async () => { + vi.useFakeTimers(); + const conversation = TestConversation.create( + {}, + createConnection({ sendMessage: vi.fn() }) + ); + + expect(conversation.isPaused()).toBe(false); + + await conversation.pause(); + expect(conversation.isPaused()).toBe(true); + + await conversation.resume(); + expect(conversation.isPaused()).toBe(false); + }); + + it("sends user activity while paused and stops after resume", async () => { + vi.useFakeTimers(); + const sendMessage = vi.fn(); + const conversation = TestConversation.create( + {}, + createConnection({ sendMessage }) + ); + + await conversation.pause(); + + expect(conversation.pauseCount).toBe(1); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenLastCalledWith({ type: "user_activity" }); + + vi.advanceTimersByTime(1000); + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(sendMessage).toHaveBeenLastCalledWith({ type: "user_activity" }); + + await conversation.resume(); + expect(conversation.resumeCount).toBe(1); + + vi.advanceTimersByTime(1000); + expect(sendMessage).toHaveBeenCalledTimes(2); + }); + + it("is idempotent", async () => { + vi.useFakeTimers(); + const conversation = TestConversation.create( + {}, + createConnection({ sendMessage: vi.fn() }) + ); + + await conversation.pause(); + await conversation.pause(); + await conversation.resume(); + await conversation.resume(); + + expect(conversation.pauseCount).toBe(1); + expect(conversation.resumeCount).toBe(1); + }); + + it("clears paused activity when the session ends", async () => { + vi.useFakeTimers(); + const sendMessage = vi.fn(); + const conversation = TestConversation.create( + {}, + createConnection({ sendMessage }) + ); + + await conversation.pause(); + await conversation.endSession(); + + vi.advanceTimersByTime(1000); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + }); + describe("uploadFile", () => { let fetchSpy: ReturnType>; diff --git a/packages/client/src/BaseConversation.ts b/packages/client/src/BaseConversation.ts index 3e696a9af..9755ad53f 100644 --- a/packages/client/src/BaseConversation.ts +++ b/packages/client/src/BaseConversation.ts @@ -30,6 +30,7 @@ import type { InputConfig } from "./utils/input.js"; import type { OutputConfig } from "./utils/output.js"; const HTTPS_API_ORIGIN = "https://api.elevenlabs.io"; +const PAUSED_ACTIVITY_INTERVAL_MS = 1000; export type { Role, Mode, Status, Callbacks } from "@elevenlabs/types"; export { CALLBACK_KEYS } from "@elevenlabs/types"; @@ -84,6 +85,8 @@ export type ClientToolsConfig = { >; }; +type ResumeAfterPauseHandler = () => void | Promise; + export function isTextOnly(options: PartialOptions): boolean | undefined { const { textOnly: textOnlyOverride } = options.overrides?.conversation ?? {}; const { textOnly } = options; @@ -112,6 +115,9 @@ export abstract class BaseConversation { protected currentEventId = 1; protected lastFeedbackEventId = 0; protected canSendFeedback = false; + protected paused = false; + private pausedActivityInterval: ReturnType | null = null; + private resumeAfterPause: ResumeAfterPauseHandler | null = null; protected static getFullOptions(partialOptions: PartialOptions): Options { const textOnly = isTextOnly(partialOptions); @@ -159,6 +165,9 @@ export abstract class BaseConversation { private endSessionWithDetails = async (details: DisconnectionDetails) => { if (this.status !== "connected" && this.status !== "connecting") return; this.updateStatus("disconnecting"); + this.stopPausedActivityInterval(); + this.resumeAfterPause = null; + this.paused = false; await this.handleEndSession(); this.updateStatus("disconnected"); if (this.options.onDisconnect) { @@ -170,6 +179,50 @@ export abstract class BaseConversation { this.connection.close(); } + protected abstract handlePause(): Promise; + + private startPausedActivityInterval() { + this.sendUserActivity(); + this.pausedActivityInterval = setInterval(() => { + this.sendUserActivity(); + }, PAUSED_ACTIVITY_INTERVAL_MS); + } + + private stopPausedActivityInterval() { + if (this.pausedActivityInterval) { + clearInterval(this.pausedActivityInterval); + this.pausedActivityInterval = null; + } + } + + public async pause(): Promise { + if (this.paused) return; + + this.paused = true; + try { + this.resumeAfterPause = await this.handlePause(); + this.startPausedActivityInterval(); + } catch (error) { + this.resumeAfterPause = null; + this.paused = false; + throw error; + } + } + + public async resume(): Promise { + if (!this.paused) return; + + this.stopPausedActivityInterval(); + try { + await this.resumeAfterPause?.(); + this.resumeAfterPause = null; + this.paused = false; + } catch (error) { + this.startPausedActivityInterval(); + throw error; + } + } + protected updateMode(mode: Mode) { if (mode !== this.mode) { this.mode = mode; @@ -314,6 +367,8 @@ export abstract class BaseConversation { protected handleAudio(event: AgentAudioEvent) {} + protected abstract shouldHandleAudio(event: AgentAudioEvent): boolean; + protected handleMCPToolCall(event: MCPToolCallClientEvent) { if (this.options.onMCPToolCall) { this.options.onMCPToolCall(event.mcp_tool_call); @@ -426,6 +481,9 @@ export abstract class BaseConversation { return; } case "audio": { + if (!this.shouldHandleAudio(parsedEvent)) { + return; + } this.handleAudio(parsedEvent); return; } @@ -514,6 +572,10 @@ export abstract class BaseConversation { return this.status === "connected"; } + public isPaused() { + return this.paused; + } + public abstract setVolume(options: { volume: number }): void; public abstract setMicMuted(isMuted: boolean): void; /** diff --git a/packages/client/src/OutputController.ts b/packages/client/src/OutputController.ts index 700f34c7c..620a522a9 100644 --- a/packages/client/src/OutputController.ts +++ b/packages/client/src/OutputController.ts @@ -9,6 +9,7 @@ export interface OutputController { setDevice(config?: Partial & OutputDeviceConfig): Promise; setVolume(volume: number): void; interrupt(resetDuration?: number): void; + setPlaybackEnabled(isEnabled: boolean): void; /** * @deprecated AnalyserNode is a web-only API and will not work on all diff --git a/packages/client/src/TextConversation.ts b/packages/client/src/TextConversation.ts index 84b05441b..138b3f904 100644 --- a/packages/client/src/TextConversation.ts +++ b/packages/client/src/TextConversation.ts @@ -8,6 +8,14 @@ const EMPTY_FREQUENCY_DATA = new Uint8Array(0); export class TextConversation extends BaseConversation { readonly type = "text"; + protected override async handlePause(): Promise<() => Promise> { + throw new Error("pause is not supported in text conversations"); + } + + protected override shouldHandleAudio(): boolean { + return false; + } + public setVolume(): void { throw new Error("setVolume is not supported in text conversations"); } diff --git a/packages/client/src/VoiceConversation.ts b/packages/client/src/VoiceConversation.ts index b0a379d4a..1b4fd0124 100644 --- a/packages/client/src/VoiceConversation.ts +++ b/packages/client/src/VoiceConversation.ts @@ -193,6 +193,34 @@ export class VoiceConversation extends BaseConversation { } } + protected override async handlePause(): Promise<() => Promise> { + const pausedMicMuted = this.input.isMuted(); + const pausedVolume = this.volume; + this.output.setPlaybackEnabled(false); + try { + this.output.interrupt(0); + this.setVolume({ volume: 0 }); + await this.input.setMuted(true); + this.updateMode("listening"); + } catch (error) { + this.output.setPlaybackEnabled(true); + throw error; + } + + return async () => { + try { + this.setVolume({ volume: pausedVolume }); + await this.input.setMuted(pausedMicMuted); + } finally { + this.output.setPlaybackEnabled(true); + } + }; + } + + protected override shouldHandleAudio(_event: AgentAudioEvent): boolean { + return !this.paused; + } + private static readonly FREQUENCY_BIN_COUNT = 1024; public setMicMuted(isMuted: boolean) { diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7ac250dcb..e193b5dec 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -747,6 +747,7 @@ describe("Volume Control", () => { gain: { value: 1, cancelScheduledValues: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), }, })), createMediaStreamSource: vi.fn(() => ({ @@ -822,6 +823,7 @@ describe("Volume Control", () => { gain: { value: 1, cancelScheduledValues: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), }, connect: vi.fn(), }; @@ -929,6 +931,151 @@ describe("Volume Control", () => { server.close(); }); + it("pauses and resumes WebSocket voice conversations", async () => { + const server = new Server("wss://api.elevenlabs.io/voice/pause-test"); + const clientPromise = new Promise((resolve, reject) => { + server.on("connection", socket => resolve(socket)); + server.on("error", reject); + setTimeout(() => reject(new Error("timeout")), 5000); + }); + + const mockGainNode = { + gain: { + value: 1, + cancelScheduledValues: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + }; + const workletPort = { + postMessage: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + start: vi.fn(), + }; + + vi.stubGlobal( + "AudioContext", + vi.fn(function MockAudioContext() { + return { + sampleRate: 16000, + currentTime: 0, + createAnalyser: vi.fn(() => ({ + connect: vi.fn(), + frequencyBinCount: 1024, + getByteFrequencyData: vi.fn(), + })), + createGain: vi.fn(() => mockGainNode), + createMediaStreamSource: vi.fn(() => ({ + connect: vi.fn(), + disconnect: vi.fn(), + })), + createMediaStreamDestination: vi.fn(() => ({ + stream: new MediaStream(), + connect: vi.fn(), + })), + destination: {}, + audioWorklet: { + addModule: vi.fn(() => Promise.resolve()), + }, + resume: vi.fn(() => Promise.resolve()), + close: vi.fn(() => Promise.resolve()), + }; + }) as unknown as typeof AudioContext + ); + + vi.stubGlobal( + "AudioWorkletNode", + vi.fn(function MockAudioWorkletNode() { + return { + connect: vi.fn(), + port: workletPort, + }; + }) as unknown as typeof AudioWorkletNode + ); + + const conversationPromise = Conversation.startSession({ + signedUrl: "wss://api.elevenlabs.io/voice/pause-test", + connectionDelay: { default: 0 }, + textOnly: false, + }); + + const client = await clientPromise; + const onMessageSend = vi.fn(); + client.on("message", onMessageSend); + + client.send( + JSON.stringify({ + type: "conversation_initiation_metadata", + conversation_initiation_metadata_event: { + conversation_id: CONVERSATION_ID, + agent_output_audio_format: OUTPUT_AUDIO_FORMAT, + }, + }) + ); + + const conversation = await conversationPromise; + conversation.setVolume({ volume: 0.4 }); + + await conversation.pause(); + await sleep(100); + + expect(mockGainNode.gain.value).toBe(0); + expect(workletPort.postMessage).toHaveBeenCalledWith({ type: "interrupt" }); + expect(onMessageSend).toHaveBeenCalledWith( + JSON.stringify({ type: "user_activity" }) + ); + onMessageSend.mockClear(); + + client.send( + JSON.stringify({ + type: "audio", + audio_event: { + audio_base_64: chunk, + event_id: Date.now(), + }, + }) + ); + await sleep(100); + + expect(workletPort.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "buffer" }) + ); + + onMessageSend.mockClear(); + await conversation.resume(); + + expect(mockGainNode.gain.value).toBe(0.4); + expect(workletPort.postMessage).toHaveBeenCalledWith({ + type: "clearInterrupted", + }); + await sleep(1100); + expect(onMessageSend).not.toHaveBeenCalledWith( + JSON.stringify({ type: "user_activity" }) + ); + + client.send( + JSON.stringify({ + type: "audio", + audio_event: { + audio_base_64: chunk, + event_id: Date.now() + 1, + }, + }) + ); + await sleep(100); + + expect(workletPort.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "buffer" }) + ); + expect(onMessageSend).not.toHaveBeenCalledWith( + JSON.stringify({ type: "user_activity" }) + ); + + await conversation.endSession(); + server.close(); + }); + it("applies volume to new audio chunks in WebSocket connection", async () => { const server = new Server( "wss://api.elevenlabs.io/voice/volume-audio-test" @@ -944,6 +1091,7 @@ describe("Volume Control", () => { gain: { value: 1, cancelScheduledValues: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), }, connect: vi.fn(), }; diff --git a/packages/client/src/utils/WebRTCConnection.test.ts b/packages/client/src/utils/WebRTCConnection.test.ts index 4bc275a81..346fd4bbb 100644 --- a/packages/client/src/utils/WebRTCConnection.test.ts +++ b/packages/client/src/utils/WebRTCConnection.test.ts @@ -298,4 +298,71 @@ describe("WebRTCConnection", () => { } } ); + + it("toggles LiveKit remote audio tracks for playback control", async () => { + const mockRoom = new Room() as any; + let trackSubscribed: ( + track: unknown, + publication: unknown, + participant: { identity: string } + ) => void = ( + _track: unknown, + _publication: unknown, + _participant: { identity: string } + ) => { + throw new Error("trackSubscribed handler was not registered"); + }; + + (mockRoom.on as ReturnType).mockImplementation( + (event: string, callback: (...args: any[]) => void) => { + if (event === "connected") { + queueMicrotask(callback); + } + if (event === "trackSubscribed") { + trackSubscribed = callback; + } + } + ); + (mockRoom.once as ReturnType).mockImplementation( + (event: string, callback: () => void) => { + if (event === "signalConnected") { + queueMicrotask(callback); + } + } + ); + + vi.stubGlobal("document", { + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + }); + + const connection = await WebRTCConnection.create({ + conversationToken: "test-token", + connectionType: "webrtc", + }); + + const remoteAudioTrack = { + kind: "audio", + setMuted: vi.fn(), + setVolume: vi.fn(), + attach: vi.fn(() => ({ + autoplay: false, + controls: true, + style: {}, + })), + mediaStreamTrack: { id: "agent-track", kind: "audio" }, + }; + + trackSubscribed?.(remoteAudioTrack, {}, { identity: "agent" }); + + connection.output.setPlaybackEnabled(false); + expect(remoteAudioTrack.setMuted).toHaveBeenLastCalledWith(true); + + connection.output.setPlaybackEnabled(true); + expect(remoteAudioTrack.setMuted).toHaveBeenLastCalledWith(false); + + connection.close(); + }); }); diff --git a/packages/client/src/utils/WebRTCConnection.ts b/packages/client/src/utils/WebRTCConnection.ts index 76f5f419d..fbc134dce 100644 --- a/packages/client/src/utils/WebRTCConnection.ts +++ b/packages/client/src/utils/WebRTCConnection.ts @@ -59,6 +59,8 @@ export class WebRTCConnection extends BaseConnection { private audioEventId = 1; private audioCaptureContext: AudioContext | null = null; private audioElements: HTMLAudioElement[] = []; + private remoteAudioTracks = new Set(); + private isPlaybackEnabled = true; private outputDeviceId: string | null = null; private inputAnalyser: AnalyserNode | null = null; @@ -199,6 +201,12 @@ export class WebRTCConnection extends BaseConnection { // No-op for WebRTC - LiveKit handles audio playback and interruption // Audio interruption is managed by the server/agent }, + setPlaybackEnabled: (isPlaybackEnabled: boolean) => { + this.isPlaybackEnabled = isPlaybackEnabled; + this.remoteAudioTracks.forEach(track => { + track.setMuted(!isPlaybackEnabled); + }); + }, getAnalyser: () => this.outputAnalyser ?? undefined, getVolume: () => this.outputVolumeProvider.getVolume(), getByteFrequencyData: (buffer: Uint8Array) => { @@ -415,6 +423,8 @@ export class WebRTCConnection extends BaseConnection { ) { // Play the audio track const remoteAudioTrack = track as RemoteAudioTrack; + this.remoteAudioTracks.add(remoteAudioTrack); + remoteAudioTrack.setMuted(!this.isPlaybackEnabled); const audioElement = remoteAudioTrack.attach(); audioElement.autoplay = true; audioElement.controls = false; @@ -450,6 +460,22 @@ export class WebRTCConnection extends BaseConnection { } ); + this.room.on( + RoomEvent.TrackUnsubscribed, + ( + track: Track, + _publication: TrackPublication, + participant: Participant + ) => { + if ( + track.kind === Track.Kind.Audio && + participant.identity.includes("agent") + ) { + this.remoteAudioTracks.delete(track as RemoteAudioTrack); + } + } + ); + this.room.on( RoomEvent.ActiveSpeakersChanged, async (speakers: Participant[]) => { @@ -515,6 +541,7 @@ export class WebRTCConnection extends BaseConnection { } }); this.audioElements = []; + this.remoteAudioTracks.clear(); this.room.disconnect(); } @@ -673,6 +700,9 @@ export class WebRTCConnection extends BaseConnection { } public setAudioVolume(volume: number) { + this.remoteAudioTracks.forEach(track => { + track.setVolume(volume); + }); this.audioElements.forEach(element => { element.volume = volume; }); diff --git a/packages/client/src/utils/output.ts b/packages/client/src/utils/output.ts index bdf82af18..bf5640ac2 100644 --- a/packages/client/src/utils/output.ts +++ b/packages/client/src/utils/output.ts @@ -123,6 +123,7 @@ export class MediaDeviceOutput } private volume = 1; + private playbackEnabled = true; private interrupted = false; private interruptTimeout: ReturnType | null = null; private readonly volumeProvider: VolumeProvider; @@ -168,7 +169,19 @@ export class MediaDeviceOutput this.gain.gain.value = volume; } + public setPlaybackEnabled(enabled: boolean): void { + this.playbackEnabled = enabled; + if (enabled) { + this.interrupted = false; + this.worklet.port.postMessage({ type: "clearInterrupted" }); + } + } + public playAudio(chunk: ArrayBuffer): void { + if (!this.playbackEnabled || this.interrupted) { + return; + } + this.gain.gain.cancelScheduledValues(this.context.currentTime); this.gain.gain.value = this.volume; if (this.interruptTimeout) { @@ -191,6 +204,16 @@ export class MediaDeviceOutput // Send interrupt message to worklet to flush queued buffers this.worklet.port.postMessage({ type: "interrupt" }); + if (resetDuration <= 0) { + this.gain.gain.cancelScheduledValues(this.context.currentTime); + this.gain.gain.value = 0; + if (this.playbackEnabled) { + this.interrupted = false; + this.worklet.port.postMessage({ type: "clearInterrupted" }); + } + return; + } + // Fade out audio gain this.gain.gain.exponentialRampToValueAtTime( 0.0001, diff --git a/packages/react/src/conversation/ConversationClientTools.test.tsx b/packages/react/src/conversation/ConversationClientTools.test.tsx index d9b6a6445..48c641dcc 100644 --- a/packages/react/src/conversation/ConversationClientTools.test.tsx +++ b/packages/react/src/conversation/ConversationClientTools.test.tsx @@ -20,8 +20,11 @@ function createContextValue( return { conversation: null, conversationRef: { current: null }, + isPaused: false, startSession: vi.fn(), endSession: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), registerCallbacks: vi.fn(), clientToolsRegistry: new Map(), clientToolsRef: { current: {} }, @@ -46,7 +49,9 @@ describe("useConversationClientTool", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); expect(() => renderHook(() => useConversationClientTool("test", () => "ok")) - ).toThrow("useConversationClientTool must be used within a ConversationProvider"); + ).toThrow( + "useConversationClientTool must be used within a ConversationProvider" + ); consoleSpy.mockRestore(); }); @@ -142,10 +147,9 @@ describe("useConversationClientTool", () => { }); const wrapper = createWrapper(value); - renderHook( - () => useConversationClientTool("shared_tool", () => "first"), - { wrapper } - ); + renderHook(() => useConversationClientTool("shared_tool", () => "first"), { + wrapper, + }); expect(() => renderHook( @@ -167,7 +171,8 @@ describe("useConversationClientTool", () => { }); const { rerender } = renderHook( - ({ name }) => useConversationClientTool(name, vi.fn().mockReturnValue("ok")), + ({ name }) => + useConversationClientTool(name, vi.fn().mockReturnValue("ok")), { wrapper: createWrapper(value), initialProps: { name: "tool_a" }, @@ -205,9 +210,7 @@ describe("buildClientTools", () => { it("throws when a hook tool conflicts with an option-provided tool", () => { const handler = vi.fn(); - const registry = new Map([ - ["duplicate", handler], - ]); + const registry = new Map([["duplicate", handler]]); expect(() => buildClientTools({ duplicate: vi.fn() }, registry)).toThrow( 'Client tool "duplicate" is already provided via props/options.' @@ -216,9 +219,7 @@ describe("buildClientTools", () => { it("handles undefined optionTools", () => { const handler = vi.fn(); - const registry = new Map([ - ["my_tool", handler], - ]); + const registry = new Map([["my_tool", handler]]); const result = buildClientTools(undefined, registry); diff --git a/packages/react/src/conversation/ConversationContext.test.tsx b/packages/react/src/conversation/ConversationContext.test.tsx index 1131b0d1f..f4f1a2a0e 100644 --- a/packages/react/src/conversation/ConversationContext.test.tsx +++ b/packages/react/src/conversation/ConversationContext.test.tsx @@ -10,6 +10,24 @@ import { } from "./ConversationContext.js"; import type { Conversation } from "@elevenlabs/client"; +function createContextValue( + overrides: Partial = {} +): ConversationContextValue { + return { + conversation: null, + conversationRef: { current: null }, + isPaused: false, + startSession: vi.fn(), + endSession: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + registerCallbacks: vi.fn(), + clientToolsRegistry: new Map(), + clientToolsRef: { current: {} }, + ...overrides, + }; +} + describe("useRawConversation", () => { it("returns null when used outside a ConversationProvider", () => { const { result } = renderHook(() => useRawConversation()); @@ -18,15 +36,10 @@ describe("useRawConversation", () => { it("returns the conversation instance from the context", () => { const mockConversation = { getId: vi.fn() } as unknown as Conversation; - const value: ConversationContextValue = { + const value = createContextValue({ conversation: mockConversation, conversationRef: { current: mockConversation }, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks: vi.fn(), - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; + }); const wrapper = ({ children }: React.PropsWithChildren) => ( @@ -39,15 +52,7 @@ describe("useRawConversation", () => { }); it("returns null when conversation is null in context", () => { - const value: ConversationContextValue = { - conversation: null, - conversationRef: { current: null }, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks: vi.fn(), - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; + const value = createContextValue(); const wrapper = ({ children }: React.PropsWithChildren) => ( @@ -70,15 +75,10 @@ describe("useRawConversationRef", () => { it("returns the conversationRef from the context", () => { const mockConversation = { getId: vi.fn() } as unknown as Conversation; const conversationRef = { current: mockConversation }; - const value: ConversationContextValue = { + const value = createContextValue({ conversation: mockConversation, conversationRef, - startSession: vi.fn(), - endSession: vi.fn(), - registerCallbacks: vi.fn(), - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; + }); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -95,21 +95,17 @@ describe("useRegisterCallbacks", () => { it("throws when used outside a ConversationProvider", () => { expect(() => renderHook(() => useRegisterCallbacks({ onConnect: vi.fn() })) - ).toThrow("useRegisterCallbacks must be used within a ConversationProvider"); + ).toThrow( + "useRegisterCallbacks must be used within a ConversationProvider" + ); }); it("calls registerCallbacks with stable wrappers and cleans up on unmount", () => { const unsubscribe = vi.fn(); const registerCallbacks = vi.fn().mockReturnValue(unsubscribe); - const value: ConversationContextValue = { - conversation: null, - conversationRef: { current: null }, - startSession: vi.fn(), - endSession: vi.fn(), + const value = createContextValue({ registerCallbacks, - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; + }); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -118,10 +114,9 @@ describe("useRegisterCallbacks", () => { ); const onConnect = vi.fn(); - const { unmount } = renderHook( - () => useRegisterCallbacks({ onConnect }), - { wrapper } - ); + const { unmount } = renderHook(() => useRegisterCallbacks({ onConnect }), { + wrapper, + }); expect(registerCallbacks).toHaveBeenCalledTimes(1); // The registered callback should delegate to the original @@ -136,15 +131,9 @@ describe("useRegisterCallbacks", () => { it("delegates to the latest callback without re-subscribing", () => { const unsubscribe = vi.fn(); const registerCallbacks = vi.fn().mockReturnValue(unsubscribe); - const value: ConversationContextValue = { - conversation: null, - conversationRef: { current: null }, - startSession: vi.fn(), - endSession: vi.fn(), + const value = createContextValue({ registerCallbacks, - clientToolsRegistry: new Map(), - clientToolsRef: { current: {} }, - }; + }); const wrapper = ({ children }: { children: React.ReactNode }) => ( diff --git a/packages/react/src/conversation/ConversationContext.tsx b/packages/react/src/conversation/ConversationContext.tsx index a6ecbfbfe..b67c9ac95 100644 --- a/packages/react/src/conversation/ConversationContext.tsx +++ b/packages/react/src/conversation/ConversationContext.tsx @@ -19,8 +19,11 @@ export type ConversationContextValue = { conversation: Conversation | null; /** Stable ref to the active conversation — use in callbacks to avoid re-renders. */ conversationRef: RefObject; + isPaused: boolean; startSession: (options?: HookOptions) => void; endSession: () => void; + pause: () => Promise; + resume: () => Promise; /** * For sub-providers — register callback handlers to be composed into the * next `Conversation.startSession()` call. Returns an unsubscribe function. diff --git a/packages/react/src/conversation/ConversationControls.test.tsx b/packages/react/src/conversation/ConversationControls.test.tsx index 479189ab4..547199c9a 100644 --- a/packages/react/src/conversation/ConversationControls.test.tsx +++ b/packages/react/src/conversation/ConversationControls.test.tsx @@ -17,6 +17,9 @@ const createMockConversation = (id = "test-id") => endSession: vi.fn().mockResolvedValue(undefined), setMicMuted: vi.fn(), setVolume: vi.fn(), + isPaused: vi.fn().mockReturnValue(false), + pause: vi.fn().mockResolvedValue(undefined), + resume: vi.fn().mockResolvedValue(undefined), sendUserMessage: vi.fn(), sendContextualUpdate: vi.fn(), sendUserActivity: vi.fn(), @@ -147,6 +150,27 @@ describe("useConversationControls", () => { expect(mockConversation.setVolume).toHaveBeenCalledWith({ volume: 0.5 }); }); + it("forwards pause and resume to the conversation", async () => { + const mockConversation = createMockConversation(); + vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); + + const { result } = renderHook(() => useConversationControls(), { + wrapper: createWrapper(), + }); + + await act(async () => { + result.current.startSession(); + }); + + await act(async () => { + await result.current.pause(); + await result.current.resume(); + }); + + expect(mockConversation.pause).toHaveBeenCalled(); + expect(mockConversation.resume).toHaveBeenCalled(); + }); + it("returns frequency data and volume from the conversation", async () => { const mockConversation = createMockConversation(); vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); @@ -198,6 +222,8 @@ describe("useConversationControls", () => { expect(() => result.current.sendUserActivity()).toThrow( "No active conversation" ); + expect(() => result.current.pause()).toThrow("No active conversation"); + expect(() => result.current.resume()).toThrow("No active conversation"); expect(() => result.current.setVolume({ volume: 0.5 })).toThrow( "No active conversation" ); diff --git a/packages/react/src/conversation/ConversationControls.tsx b/packages/react/src/conversation/ConversationControls.tsx index 91a271667..15064290f 100644 --- a/packages/react/src/conversation/ConversationControls.tsx +++ b/packages/react/src/conversation/ConversationControls.tsx @@ -15,6 +15,8 @@ const EMPTY_FREQUENCY_DATA = new Uint8Array(0); export type ConversationControlsValue = { startSession: (options?: HookOptions) => void; endSession: () => void; + pause: () => Promise; + resume: () => Promise; sendUserMessage: (text: string) => void; sendMultimodalMessage: (options: MultimodalMessageInput) => void; uploadFile: (file: Blob) => Promise; @@ -166,6 +168,8 @@ export function ConversationControlsProvider({ () => ({ startSession: ctx.startSession, endSession: ctx.endSession, + pause: ctx.pause, + resume: ctx.resume, sendUserMessage, sendMultimodalMessage, uploadFile, @@ -184,6 +188,8 @@ export function ConversationControlsProvider({ [ ctx.startSession, ctx.endSession, + ctx.pause, + ctx.resume, sendUserMessage, sendMultimodalMessage, uploadFile, diff --git a/packages/react/src/conversation/ConversationInput.test.tsx b/packages/react/src/conversation/ConversationInput.test.tsx index 798d08430..70ec127f8 100644 --- a/packages/react/src/conversation/ConversationInput.test.tsx +++ b/packages/react/src/conversation/ConversationInput.test.tsx @@ -21,6 +21,7 @@ const createMockConversation = (id = "test-id") => endSession: vi.fn().mockResolvedValue(undefined), setMicMuted: vi.fn(), setVolume: vi.fn(), + isPaused: vi.fn().mockReturnValue(false), }) as unknown as Conversation; function useTestHook() { @@ -31,11 +32,7 @@ function useTestHook() { function createWrapper(props: Record = {}) { return function Wrapper({ children }: React.PropsWithChildren) { - return ( - - {children} - - ); + return {children}; }; } @@ -146,7 +143,8 @@ describe("ConversationInput", () => { expect(result.current.input.isMuted).toBe(true); // Simulate the onDisconnect callback that the conversation instance fires - const startSessionCall = vi.mocked(Conversation.startSession).mock.calls[0][0]; + const startSessionCall = vi.mocked(Conversation.startSession).mock + .calls[0][0]; act(() => { startSessionCall?.onDisconnect?.({ reason: "agent" }); }); @@ -201,7 +199,11 @@ describe("ConversationInput", () => { React.useEffect(() => { setControlledMuted = setIsMuted; }, [setIsMuted]); - return {children}; + return ( + + {children} + + ); }; const { result } = renderHook(() => useTestHook(), { wrapper: Wrapper }); @@ -261,7 +263,10 @@ describe("ConversationInput", () => { }, []); return ( - + {children} ); @@ -302,7 +307,8 @@ describe("ConversationInput", () => { result.current.startSession(); }); - const startSessionCall = vi.mocked(Conversation.startSession).mock.calls[0][0]; + const startSessionCall = vi.mocked(Conversation.startSession).mock + .calls[0][0]; act(() => { startSessionCall?.onDisconnect?.({ reason: "agent" }); }); diff --git a/packages/react/src/conversation/ConversationPause.test.tsx b/packages/react/src/conversation/ConversationPause.test.tsx new file mode 100644 index 000000000..2a3315f29 --- /dev/null +++ b/packages/react/src/conversation/ConversationPause.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import React from "react"; +import { renderHook } from "@testing-library/react"; +import { ConversationProvider } from "./ConversationProvider.js"; +import { useConversationPause } from "./ConversationPause.js"; + +function createWrapper() { + return function Wrapper({ children }: React.PropsWithChildren) { + return {children}; + }; +} + +describe("useConversationPause", () => { + it("throws when used outside a ConversationProvider", () => { + const consoleError = console.error; + console.error = () => {}; + expect(() => renderHook(() => useConversationPause())).toThrow( + "useConversationPause must be used within a ConversationProvider" + ); + console.error = consoleError; + }); + + it("returns isPaused false initially", () => { + const { result } = renderHook(() => useConversationPause(), { + wrapper: createWrapper(), + }); + + expect(result.current.isPaused).toBe(false); + }); +}); diff --git a/packages/react/src/conversation/ConversationPause.tsx b/packages/react/src/conversation/ConversationPause.tsx new file mode 100644 index 000000000..17f4989fe --- /dev/null +++ b/packages/react/src/conversation/ConversationPause.tsx @@ -0,0 +1,46 @@ +import { createContext, useContext } from "react"; +import { ConversationContext } from "./ConversationContext.js"; + +export type ConversationPauseValue = { + isPaused: boolean; +}; + +const ConversationPauseContext = createContext( + null +); + +/** + * Reads pause state from `ConversationContext` and provides it through + * `ConversationPauseContext`. Must be rendered inside a `ConversationProvider`. + */ +export function ConversationPauseProvider({ + children, +}: React.PropsWithChildren) { + const ctx = useContext(ConversationContext); + if (!ctx) { + throw new Error( + "ConversationPauseProvider must be rendered inside a ConversationProvider" + ); + } + + return ( + + {children} + + ); +} + +/** + * Returns whether the active conversation is paused. + * + * Must be used within a `ConversationProvider`. + */ +export function useConversationPause(): ConversationPauseValue { + const ctx = useContext(ConversationPauseContext); + if (!ctx) { + throw new Error( + "useConversationPause must be used within a ConversationProvider" + ); + } + return ctx; +} diff --git a/packages/react/src/conversation/ConversationProvider.test.tsx b/packages/react/src/conversation/ConversationProvider.test.tsx index 59e360c74..1f8f91dfc 100644 --- a/packages/react/src/conversation/ConversationProvider.test.tsx +++ b/packages/react/src/conversation/ConversationProvider.test.tsx @@ -33,6 +33,7 @@ const createMockConversation = (id = "test-id") => ({ getId: vi.fn().mockReturnValue(id), isOpen: vi.fn().mockReturnValue(true), + isPaused: vi.fn().mockReturnValue(false), endSession: vi.fn().mockResolvedValue(undefined), setMicMuted: vi.fn(), setVolume: vi.fn(), diff --git a/packages/react/src/conversation/ConversationProvider.tsx b/packages/react/src/conversation/ConversationProvider.tsx index ca76890a2..9d3eaaf00 100644 --- a/packages/react/src/conversation/ConversationProvider.tsx +++ b/packages/react/src/conversation/ConversationProvider.tsx @@ -33,6 +33,7 @@ import { } from "./ConversationInput.js"; import { ConversationModeProvider } from "./ConversationMode.js"; import { ConversationFeedbackProvider } from "./ConversationFeedback.js"; +import { ConversationPauseProvider } from "./ConversationPause.js"; import { ConversationClientToolsProvider, buildClientTools, @@ -45,13 +46,15 @@ type ConversationInputControlProps = Pick< "isMuted" | "onMutedChange" >; -const SUB_PROVIDERS_WITHOUT_PROPS: React.ComponentType[] = [ - ConversationControlsProvider, - ConversationStatusProvider, - ConversationModeProvider, - ConversationFeedbackProvider, - ConversationClientToolsProvider, -]; +const SUB_PROVIDERS_WITHOUT_PROPS: React.ComponentType[] = + [ + ConversationControlsProvider, + ConversationStatusProvider, + ConversationModeProvider, + ConversationFeedbackProvider, + ConversationPauseProvider, + ConversationClientToolsProvider, + ]; export type ConversationProviderProps = React.PropsWithChildren< HookOptions & ConversationInputControlProps @@ -76,7 +79,9 @@ export function ConversationProvider({ () => new Map[string]>() ); /** Ref to the live clientTools object currently held by BaseConversation. */ - const clientToolsRef = useRef[string]>>({}); + const clientToolsRef = useRef< + Record[string]> + >({}); /** Always holds the latest provider props, avoiding stale closures in callbacks. */ const defaultOptionsRef = useRef(defaultOptions); // eslint-disable-next-line react-hooks/refs -- intentional sync during render for latest-ref pattern @@ -89,6 +94,7 @@ export function ConversationProvider({ /** Reactive mirror of conversationRef, triggers re-renders for context consumers. */ const [conversation, setConversation] = useState(null); + const [isPaused, setIsPaused] = useState(false); const stableCallbacks = useStableCallbacks(defaultOptions); @@ -105,6 +111,7 @@ export function ConversationProvider({ onDisconnect: () => { conversationRef.current = null; setConversation(null); + setIsPaused(false); }, }); }, [listenerMap]); @@ -195,12 +202,14 @@ export function ConversationProvider({ if (shouldEndRef.current) { conv.endSession(); lockRef.current = null; + setIsPaused(false); return; } if (conversationRef.current !== conv) { conversationRef.current = conv; setConversation(conv); } + setIsPaused(false); lockRef.current = null; }, (error: unknown) => { @@ -209,6 +218,7 @@ export function ConversationProvider({ } conversationRef.current = null; setConversation(null); + setIsPaused(false); lockRef.current = null; if (shouldEndRef.current) { return; @@ -218,9 +228,7 @@ export function ConversationProvider({ // so listeners (e.g. ConversationStatusProvider) transition to // the "error" state with a meaningful message. const message = - error instanceof Error - ? error.message - : "Session failed to start"; + error instanceof Error ? error.message : "Session failed to start"; sessionOptions.onError?.(message, error); } ); @@ -234,20 +242,47 @@ export function ConversationProvider({ const conv = conversationRef.current; conversationRef.current = null; setConversation(null); + setIsPaused(false); if (pendingConnection) { - pendingConnection.then(c => c.endSession(), () => {}); + pendingConnection.then( + c => c.endSession(), + () => {} + ); } else { conv?.endSession(); } }, []); + const pause = useCallback(() => { + const conversation = conversationRef.current; + if (!conversation) { + throw new Error("No active conversation. Call startSession() first."); + } + return conversation.pause().then(() => { + setIsPaused(conversation.isPaused()); + }); + }, []); + + const resume = useCallback(() => { + const conversation = conversationRef.current; + if (!conversation) { + throw new Error("No active conversation. Call startSession() first."); + } + return conversation.resume().then(() => { + setIsPaused(conversation.isPaused()); + }); + }, []); + // Cleanup on unmount useEffect(() => { return () => { shouldEndRef.current = true; if (lockRef.current) { - lockRef.current.then(conv => conv.endSession(), () => {}); + lockRef.current.then( + conv => conv.endSession(), + () => {} + ); } else { conversationRef.current?.endSession(); } @@ -258,24 +293,39 @@ export function ConversationProvider({ () => ({ conversation, conversationRef, + isPaused, startSession, endSession, + pause, + resume, registerCallbacks, clientToolsRegistry, clientToolsRef, }), - [conversation, conversationRef, startSession, endSession, registerCallbacks, clientToolsRegistry, clientToolsRef] + [ + conversation, + conversationRef, + isPaused, + startSession, + endSession, + pause, + resume, + registerCallbacks, + clientToolsRegistry, + clientToolsRef, + ] ); - const wrappedChildren = SUB_PROVIDERS_WITHOUT_PROPS.reduceRight( - (nested, Provider) => {nested}, - - {children} - - ); + const wrappedChildren = + SUB_PROVIDERS_WITHOUT_PROPS.reduceRight( + (nested, Provider) => {nested}, + + {children} + + ); return ( diff --git a/packages/react/src/conversation/useConversation.test.tsx b/packages/react/src/conversation/useConversation.test.tsx index 37afe3b1f..870f55723 100644 --- a/packages/react/src/conversation/useConversation.test.tsx +++ b/packages/react/src/conversation/useConversation.test.tsx @@ -21,6 +21,9 @@ const createMockConversation = (id = "test-id") => endSession: vi.fn().mockResolvedValue(undefined), setMicMuted: vi.fn(), setVolume: vi.fn(), + isPaused: vi.fn().mockReturnValue(false), + pause: vi.fn().mockResolvedValue(undefined), + resume: vi.fn().mockResolvedValue(undefined), sendFeedback: vi.fn(), sendUserMessage: vi.fn(), sendContextualUpdate: vi.fn(), @@ -34,13 +37,13 @@ const createMockConversation = (id = "test-id") => function createWrapper(props: Record = {}) { return function Wrapper({ children }: React.PropsWithChildren) { - return ( - {children} - ); + return {children}; }; } -type MockStartSessionOptions = Partial & +type MockStartSessionOptions = Partial< + Callbacks & ConversationLifecycleOptions +> & Record; function driveConnectedSessionLifecycle( @@ -52,7 +55,9 @@ function driveConnectedSessionLifecycle( options.onConnect?.({ conversationId: conversation.getId() }); } -function mockStartSessionWithLifecycle(conversation = createMockConversation()) { +function mockStartSessionWithLifecycle( + conversation = createMockConversation() +) { vi.mocked(Conversation.startSession).mockImplementation(async options => { driveConnectedSessionLifecycle( options as MockStartSessionOptions, @@ -83,11 +88,41 @@ describe("useConversation", () => { expect(result.current.isMuted).toBe(false); expect(result.current.isSpeaking).toBe(false); expect(result.current.isListening).toBe(true); + expect(result.current.isPaused).toBe(false); expect(result.current.canSendFeedback).toBe(false); expect(typeof result.current.startSession).toBe("function"); expect(typeof result.current.endSession).toBe("function"); }); + it("returns pause state", async () => { + const mockConversation = createMockConversation(); + vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); + + const { result } = renderHook(() => useConversation(), { + wrapper: createWrapper(), + }); + + await act(async () => { + result.current.startSession({ signedUrl: "wss://test.example.com" }); + }); + + vi.mocked(mockConversation.isPaused).mockReturnValue(true); + + await act(async () => { + await result.current.pause(); + }); + + expect(result.current.isPaused).toBe(true); + + vi.mocked(mockConversation.isPaused).mockReturnValue(false); + + await act(async () => { + await result.current.resume(); + }); + + expect(result.current.isPaused).toBe(false); + }); + it("cancels session when endSession is called during connection", async () => { const mockConversation = createMockConversation(); const { promise, resolve: resolveStartSession } = @@ -153,9 +188,8 @@ describe("useConversation", () => { vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); const { result, rerender } = renderHook( - ({ micMuted }: { micMuted?: boolean }) => - useConversation({ micMuted }), - { wrapper: createWrapper(), initialProps: {} }, + ({ micMuted }: { micMuted?: boolean }) => useConversation({ micMuted }), + { wrapper: createWrapper(), initialProps: {} } ); await act(async () => { @@ -173,10 +207,9 @@ describe("useConversation", () => { const mockConversation = createMockConversation(); vi.mocked(Conversation.startSession).mockResolvedValue(mockConversation); - const { result } = renderHook( - () => useConversation({ micMuted: true }), - { wrapper: createWrapper() }, - ); + const { result } = renderHook(() => useConversation({ micMuted: true }), { + wrapper: createWrapper(), + }); expect(result.current.isMuted).toBe(true); @@ -195,7 +228,7 @@ describe("useConversation", () => { const { result } = renderHook( () => useConversation({ onConnect, onError }), - { wrapper: createWrapper() }, + { wrapper: createWrapper() } ); await act(async () => { @@ -208,7 +241,9 @@ describe("useConversation", () => { // onError is still passed through to the SDK — invoke it manually const [[opts]] = vi.mocked(Conversation.startSession).mock.calls; opts.onError!("something went wrong", { type: "unknown" }); - expect(onError).toHaveBeenCalledWith("something went wrong", { type: "unknown" }); + expect(onError).toHaveBeenCalledWith("something went wrong", { + type: "unknown", + }); }); it("composes hook callbacks with provider callbacks", async () => { @@ -218,7 +253,7 @@ describe("useConversation", () => { const { result } = renderHook( () => useConversation({ onConnect: hookOnConnect }), - { wrapper: createWrapper({ onConnect: providerOnConnect }) }, + { wrapper: createWrapper({ onConnect: providerOnConnect }) } ); await act(async () => { @@ -226,7 +261,9 @@ describe("useConversation", () => { }); // onConnect is forwarded through the provider-owned wrapper. - expect(providerOnConnect).toHaveBeenCalledWith({ conversationId: "test-id" }); + expect(providerOnConnect).toHaveBeenCalledWith({ + conversationId: "test-id", + }); expect(hookOnConnect).toHaveBeenCalledWith({ conversationId: "test-id" }); }); @@ -236,7 +273,7 @@ describe("useConversation", () => { const { result } = renderHook( () => useConversation({ onConnect: () => calls.push("hook") }), - { wrapper: createWrapper({ onConnect: () => calls.push("provider") }) }, + { wrapper: createWrapper({ onConnect: () => calls.push("provider") }) } ); await act(async () => { @@ -266,7 +303,7 @@ describe("useConversation", () => { { wrapper: createWrapper(), initialProps: { cb: () => calls.push("first") }, - }, + } ); act(() => { @@ -298,7 +335,7 @@ describe("useConversation", () => { const { result } = renderHook( () => useConversation({ agentId: "hook-agent-id" }), - { wrapper: createWrapper() }, + { wrapper: createWrapper() } ); await act(async () => { @@ -315,7 +352,7 @@ describe("useConversation", () => { const { result } = renderHook( () => useConversation({ agentId: "hook-agent-id" }), - { wrapper: createWrapper() }, + { wrapper: createWrapper() } ); await act(async () => { @@ -333,7 +370,7 @@ describe("useConversation", () => { const { result } = renderHook( () => useConversation({ agentId: "hook-agent-id", onConnect }), - { wrapper: createWrapper() }, + { wrapper: createWrapper() } ); await act(async () => { @@ -346,8 +383,8 @@ describe("useConversation", () => { expect(opts.agentId).toBe("hook-agent-id"); expect(typeof opts.onConnect).toBe("function"); expect(opts.onConnect).not.toBe(onConnect); - expect( - typeof (opts as MockStartSessionOptions).onConversationCreated - ).toBe("function"); + expect(typeof (opts as MockStartSessionOptions).onConversationCreated).toBe( + "function" + ); }); }); diff --git a/packages/react/src/conversation/useConversation.ts b/packages/react/src/conversation/useConversation.ts index 30c1c101f..420340f64 100644 --- a/packages/react/src/conversation/useConversation.ts +++ b/packages/react/src/conversation/useConversation.ts @@ -6,6 +6,7 @@ import { useConversationStatus } from "./ConversationStatus.js"; import { useConversationInput } from "./ConversationInput.js"; import { useConversationMode } from "./ConversationMode.js"; import { useConversationFeedback } from "./ConversationFeedback.js"; +import { useConversationPause } from "./ConversationPause.js"; import { useRawConversation, useRegisterCallbacks, @@ -45,6 +46,7 @@ export function useConversation(props: UseConversationOptions = {}) { const { isMuted, setMuted } = useConversationInput(); const { mode, isSpeaking, isListening } = useConversationMode(); const { canSendFeedback, sendFeedback } = useConversationFeedback(); + const { isPaused } = useConversationPause(); const startSession = useCallback( (options?: HookOptions) => { @@ -91,6 +93,7 @@ export function useConversation(props: UseConversationOptions = {}) { mode, isSpeaking, isListening, + isPaused, canSendFeedback, sendFeedback, }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d560b121b..90ed213da 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -28,6 +28,7 @@ export { useConversationControls } from "./conversation/ConversationControls.js" export { useConversationStatus } from "./conversation/ConversationStatus.js"; export { useConversationInput } from "./conversation/ConversationInput.js"; export { useConversationMode } from "./conversation/ConversationMode.js"; +export { useConversationPause } from "./conversation/ConversationPause.js"; export { useConversationFeedback } from "./conversation/ConversationFeedback.js"; export { useRawConversation } from "./conversation/ConversationContext.js"; export { useConversation } from "./conversation/useConversation.js"; @@ -40,6 +41,7 @@ export type { ConversationStatusValue, } from "./conversation/ConversationStatus.js"; export type { ConversationModeValue } from "./conversation/ConversationMode.js"; +export type { ConversationPauseValue } from "./conversation/ConversationPause.js"; export type { ConversationFeedbackValue } from "./conversation/ConversationFeedback.js"; export type { ConversationProviderProps } from "./conversation/ConversationProvider.js"; export type {