@@ -6,9 +6,8 @@ import LiveKit
66
77/// A single-use conversation session, created by and owned by `ConversationClient`.
88///
9- /// Manages the lifecycle of one conversation: network layer
10- /// (`WebRTCConnectionManager`|`WebSocketConnectionManager`), protocol parser
11- /// (`EventParser`), and audio device setup.
9+ /// Owns one conversation's transport (`WebRTCConnectionManager` or
10+ /// `WebSocketConnectionManager`) and audio device setup.
1211@MainActor
1312final class Conversation : ObservableObject {
1413 // MARK: - State
@@ -17,8 +16,6 @@ final class Conversation: ObservableObject {
1716 @Published var chatHistory : [ any ChatHistoryItem ] = [ ]
1817 @Published var agentState : AgentState = . listening
1918
20- private var chatHistoryReconciler = ChatHistoryReconciler ( )
21-
2219 /// Stream of client tool calls that need to be executed by the app
2320 @Published var pendingToolCalls : [ ClientToolCallEvent ] = [ ]
2421
@@ -31,38 +28,29 @@ final class Conversation: ObservableObject {
3128 /// Current MCP connection status for all integrations
3229 @Published var mcpConnectionStatus : MCPConnectionStatusEvent ?
3330
34- /// Pending mute state to apply after connection completes.
35- /// Allows setting mute state during connection phase.
36- private var pendingMuteState : Bool ?
31+ private var chatHistoryReconciler = ChatHistoryReconciler ( )
32+
33+ /// Agent state manager for event-based state tracking
34+ private var agentStateManager : AgentStateManager ?
3735
3836 /// Audio device management
3937 private var audioManager : ConversationAudioManager ?
4038
39+ /// Pending mute state to apply after connection completes.
40+ /// Allows setting mute state during connection phase.
41+ private var pendingMuteState : Bool ?
42+
4143 /// Externally registered audio observers. Kept attached across track swaps.
4244 let agentObserverRegistry = AudioObserverRegistry ( )
4345 let micObserverRegistry = AudioObserverRegistry ( )
4446
45- /// Agent state manager for event-based state tracking
46- var agentStateManager : AgentStateManager ?
47-
48- /// Forward a signal to the event-based state manager, or fall back to directly setting `agentState`.
49- func applyStateSignal( _ signal: AgentStateSignal , fallback: AgentState ) {
50- if let manager = agentStateManager {
51- manager. processSignal ( signal)
52- } else {
53- agentState = fallback
54- }
47+ /// Internal LiveKit tracks used to attach ``ConversationAudioObserver``s.
48+ private var inputTrack : ( any AudioTrackProtocol ) ? {
49+ activeWebRTCConnectionManager? . inputTrack
5550 }
5651
57- func handleRemoteSpeakingUpdate( isSpeaking: Bool ) {
58- if let manager = agentStateManager {
59- manager. processSignal ( isSpeaking ? . agentStartedSpeaking : . agentStoppedSpeaking)
60- } else if isSpeaking {
61- speakingTimer? . cancel ( )
62- agentState = . speaking
63- } else {
64- scheduleBackToListening ( delay: 1.0 )
65- }
52+ private var agentAudioTrack : ( any AudioTrackProtocol ) ? {
53+ activeWebRTCConnectionManager? . agentAudioTrack
6654 }
6755
6856 /// Internal logger, accessible from nonisolated contexts.
@@ -71,14 +59,10 @@ final class Conversation: ObservableObject {
7159 /// Context for logging (e.g. agentId)
7260 private var activeContext : [ String : String ] ?
7361
74- /// Internal LiveKit tracks used to attach ``ConversationAudioObserver``s.
75- var inputTrack : ( any AudioTrackProtocol ) ? {
76- activeWebRTCConnectionManager? . inputTrack
77- }
78-
79- var agentAudioTrack : ( any AudioTrackProtocol ) ? {
80- activeWebRTCConnectionManager? . agentAudioTrack
81- }
62+ let config : ConversationConfig
63+ let callbacks : ConversationCallbacks
64+ private var speakingTimer : Task < Void , Never > ?
65+ private var isTearingDown = false
8266
8367 // MARK: - Init
8468
@@ -95,115 +79,63 @@ final class Conversation: ObservableObject {
9579 logger = dependencyProvider. logger
9680 }
9781
98- private func setupAudioManager( ) {
99- audioManager = ConversationAudioManager ( logger: logger)
100- }
101-
102- private func setupAgentStateManager( ) {
103- guard let configuration = config. agentStateConfiguration else { return }
104- let manager = AgentStateManager ( configuration: configuration)
105- manager. onStateChange = { [ weak self] state in
106- self ? . agentState = state
107- self ? . callbacks. onAgentStateChange ? ( state)
108- }
109- agentStateManager = manager
110- }
111-
11282 // MARK: - API
11383
11484 func startVoiceConversation( _ auth: ConversationAuth . Voice ) async throws -> ConversationStartResult {
115- try await runStart ( isTextOnly: false ) { config in
116- try await performVoiceStart ( auth, config: config)
117- }
118- }
119-
120- func startTextConversation( _ auth: ConversationAuth . TextOnly ) async throws -> ConversationStartResult {
121- try await runStart ( isTextOnly: true ) { config in
122- try await performTextStart ( auth, config: config)
123- }
124- }
85+ let manager = dependencyProvider. webRTCConnectionManager
86+ let audioManager = ConversationAudioManager ( logger: logger)
87+ self . audioManager = audioManager
12588
126- private func runStart(
127- isTextOnly: Bool ,
128- _ work: ( ConversationConfig ) async throws -> ConversationStartResult
129- ) async throws -> ConversationStartResult {
130- guard state == . idle else {
131- throw ConversationError . alreadyStarted
132- }
133-
134- var startConfig = config
135- startConfig. conversationOverrides. textOnly = isTextOnly
136- let result = try await work ( startConfig)
137-
138- guard !Task. isCancelled, state. isConnecting else {
139- await activeConnectionManager? . disconnect ( )
140- throw CancellationError ( )
141- }
142- state = . connected( result. callInfo)
143- callbacks. onAgentReady ? ( )
144- return result
145- }
146-
147- private func performVoiceStart(
148- _ auth: ConversationAuth . Voice ,
149- config: ConversationConfig
150- ) async throws -> ConversationStartResult {
151- let webRTCConnectionManager = dependencyProvider. webRTCConnectionManager
152- setupAudioManager ( )
153- prepareConversationStart (
154- agentId: auth. agentId,
155- isTextOnly: false ,
156- connectionManager: webRTCConnectionManager
157- )
158-
159- webRTCConnectionManager. onRemoteSpeakingChanged = { [ weak self] isSpeaking in
89+ manager. onRemoteSpeakingChanged = { [ weak self] isSpeaking in
16090 Task { @MainActor in
16191 self ? . handleRemoteSpeakingUpdate ( isSpeaking: isSpeaking)
16292 }
16393 }
164- webRTCConnectionManager . onTracksChanged = { [ weak self] in
94+ manager . onTracksChanged = { [ weak self] in
16595 Task { @MainActor in
16696 self ? . refreshAudioObservers ( )
16797 }
16898 }
169-
170- await audioManager? . configure ( with: config, callbacks: callbacks)
99+ await audioManager. configure ( with: config, callbacks: callbacks)
171100 if let pendingMuteState {
172- audioManager? . softwareMuteProcessor? . setMuted ( pendingMuteState)
101+ audioManager. softwareMuteProcessor? . setMuted ( pendingMuteState)
173102 }
174103
175- let result : ConversationStartResult
176- do {
177- result = try await webRTCConnectionManager. connect (
104+ let result = try await start ( agentId: auth. agentId, isTextOnly: false , using: manager) { config in
105+ try await manager. connect (
178106 auth: auth,
179107 config: config,
180- onStartupStateChange: { [ weak self] stage in
181- self ? . updateStartupStage ( stage)
182- }
108+ onStartupStateChange: { [ weak self] in self ? . updateStartupStage ( $0) }
183109 )
184- } catch let error as ConversationError {
185- await handleStartupFailure ( error, disconnecting: webRTCConnectionManager)
186- throw error
187- } catch is CancellationError {
188- await handleStartupCancellation ( disconnecting: webRTCConnectionManager)
189- throw CancellationError ( )
190110 }
191111
192112 if let pendingMute = pendingMuteState {
193113 pendingMuteState = nil
194114 do {
195- if let softwareMuteProcessor = audioManager? . softwareMuteProcessor {
115+ if let softwareMuteProcessor = audioManager. softwareMuteProcessor {
196116 softwareMuteProcessor. setMuted ( pendingMute)
197117 } else {
198- try await webRTCConnectionManager . setMicrophoneMuted ( pendingMute)
118+ try await manager . setMicrophoneMuted ( pendingMute)
199119 }
200120 } catch {
201121 logger. warning ( " Failed to apply pending mute state " , context: [ " error " : " \( error) " ] )
202122 }
203123 }
204124
205125 refreshAudioObservers ( )
206- return result
126+ return try await setConnected ( result)
127+ }
128+
129+ func startTextConversation( _ auth: ConversationAuth . TextOnly ) async throws -> ConversationStartResult {
130+ let manager = dependencyProvider. webSocketConnectionManager
131+ let result = try await start ( agentId: auth. agentId, isTextOnly: true , using: manager) { config in
132+ try await manager. connect (
133+ auth: auth,
134+ config: config,
135+ onStartupStateChange: { [ weak self] in self ? . updateStartupStage ( $0) }
136+ )
137+ }
138+ return try await setConnected ( result)
207139 }
208140
209141 // MARK: - Audio observers
@@ -237,38 +169,6 @@ final class Conversation: ObservableObject {
237169 micObserverRegistry. attach ( to: inputTrack)
238170 }
239171
240- private func performTextStart(
241- _ auth: ConversationAuth . TextOnly ,
242- config: ConversationConfig
243- ) async throws -> ConversationStartResult {
244- let connectionManager = dependencyProvider. webSocketConnectionManager
245- let agentId = switch auth {
246- case let . publicAgent( id) : id
247- case . signedWebSocketURL: " unknown "
248- }
249- prepareConversationStart (
250- agentId: agentId,
251- isTextOnly: true ,
252- connectionManager: connectionManager
253- )
254-
255- do {
256- return try await connectionManager. connect (
257- auth: auth,
258- config: config,
259- onStartupStateChange: { [ weak self] stage in
260- self ? . updateStartupStage ( stage)
261- }
262- )
263- } catch let error as ConversationError {
264- await handleStartupFailure ( error, disconnecting: connectionManager)
265- throw error
266- } catch is CancellationError {
267- await handleStartupCancellation ( disconnecting: connectionManager)
268- throw CancellationError ( )
269- }
270- }
271-
272172 /// End and clean up.
273173 /// Can be called during connection phase to cancel, or during connected conversation to end.
274174 func endConversation( reason: EndReason = . userEnded) async {
@@ -336,7 +236,7 @@ final class Conversation: ObservableObject {
336236 try await publish ( event)
337237 }
338238
339- /// Contextual update to agent (system prompt-ish) .
239+ /// Inject context for the agent without interrupting or adding a user-visible message .
340240 func updateContext( _ context: String ) async throws {
341241 guard state. isConnected else { throw ConversationError . notConnected }
342242 let event = OutgoingEvent . contextualUpdate ( ContextualUpdateEvent ( text: context) )
@@ -383,42 +283,93 @@ final class Conversation: ObservableObject {
383283 activeConnectionManager as? any WebRTCConnectionManaging
384284 }
385285
386- let config : ConversationConfig
387- let callbacks : ConversationCallbacks
388-
389- var speakingTimer : Task < Void , Never > ?
390- private var isTearingDown = false
391-
392286 private func updateStartupStage( _ stage: ConversationStartupState ) {
393287 guard state. isConnecting, state != . connecting( stage) else { return }
394288 state = . connecting( stage)
395289 }
396290
397- /// Common preparation shared by voice and text-only startup paths.
398- private func prepareConversationStart(
291+ private func setupAgentStateManager( ) {
292+ guard let configuration = config. agentStateConfiguration else { return }
293+ let manager = AgentStateManager ( configuration: configuration)
294+ manager. onStateChange = { [ weak self] state in
295+ self ? . agentState = state
296+ self ? . callbacks. onAgentStateChange ? ( state)
297+ }
298+ agentStateManager = manager
299+ }
300+
301+ /// Forward a signal to the event-based state manager, or fall back to directly setting `agentState`.
302+ private func applyStateSignal( _ signal: AgentStateSignal , fallback: AgentState ) {
303+ if let manager = agentStateManager {
304+ manager. processSignal ( signal)
305+ } else {
306+ agentState = fallback
307+ }
308+ }
309+
310+ private func handleRemoteSpeakingUpdate( isSpeaking: Bool ) {
311+ if let manager = agentStateManager {
312+ manager. processSignal ( isSpeaking ? . agentStartedSpeaking : . agentStoppedSpeaking)
313+ } else if isSpeaking {
314+ speakingTimer? . cancel ( )
315+ agentState = . speaking
316+ } else {
317+ scheduleBackToListening ( delay: 1.0 )
318+ }
319+ }
320+
321+ private func start(
399322 agentId: String ,
400323 isTextOnly: Bool ,
401- connectionManager: any ConnectionManaging
402- ) {
324+ using manager: any ConnectionManaging ,
325+ connect: ( ConversationConfig ) async throws -> ConversationStartResult
326+ ) async throws -> ConversationStartResult {
327+ guard state == . idle else {
328+ throw ConversationError . alreadyStarted
329+ }
330+
331+ var startConfig = config
332+ startConfig. conversationOverrides. textOnly = isTextOnly
333+
403334 state = . connecting( . preparing)
404- activeConnectionManager = connectionManager
335+ activeConnectionManager = manager
405336
406337 activeContext = [ " agentId " : agentId]
407338 let mode = isTextOnly ? " text-only " : " voice "
408339 logger. info ( " Starting \( mode) conversation " , context: activeContext)
409340
410341 setupAgentStateManager ( )
411342
412- let connectionManagerID = ObjectIdentifier ( connectionManager )
413- connectionManager . onEventReceived = { [ weak self] event in
343+ let connectionManagerID = ObjectIdentifier ( manager )
344+ manager . onEventReceived = { [ weak self] event in
414345 Task { @MainActor [ weak self] in
415346 await self ? . handleIncomingEvent ( event, from: connectionManagerID)
416347 }
417348 }
418- connectionManager . onDisconnected = { [ weak self] in
349+ manager . onDisconnected = { [ weak self] in
419350 guard let self else { return }
420351 await endConversation ( reason: . remoteDisconnected)
421352 }
353+
354+ do {
355+ return try await connect ( startConfig)
356+ } catch let error as ConversationError {
357+ await handleStartupFailure ( error, disconnecting: manager)
358+ throw error
359+ } catch is CancellationError {
360+ await handleStartupCancellation ( disconnecting: manager)
361+ throw CancellationError ( )
362+ }
363+ }
364+
365+ private func setConnected( _ result: ConversationStartResult ) async throws -> ConversationStartResult {
366+ guard !Task. isCancelled, state. isConnecting else {
367+ await activeConnectionManager? . disconnect ( )
368+ throw CancellationError ( )
369+ }
370+ state = . connected( result. callInfo)
371+ callbacks. onAgentReady ? ( )
372+ return result
422373 }
423374
424375 private func handleIncomingEvent( _ event: IncomingEvent , from connectionManagerID: ObjectIdentifier ) async {
@@ -488,7 +439,7 @@ final class Conversation: ObservableObject {
488439 }
489440 }
490441
491- func publish( _ event: OutgoingEvent ) async throws {
442+ private func publish( _ event: OutgoingEvent ) async throws {
492443 guard let connectionManager = activeConnectionManager else {
493444 throw ConversationError . notConnected
494445 }
0 commit comments