From 285e8cfade1be4509070746a88fd1a7195b2f8ee Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:03:02 +0100 Subject: [PATCH 1/8] chore: finish deprecated-symbol sweep, swiftformat --- .../Public/Conversation/Conversation.swift | 15 --------- .../ConversationAgentReadyReport.swift | 8 +---- .../ConversationStartupConfiguration.swift | 10 +----- .../Startup/ConversationStartupMetrics.swift | 6 ---- .../ElevenLabs/Events/OutgoingEvents.swift | 32 ------------------- .../Public/Models/ReceivedMessage.swift | 14 -------- .../Public/Models/SentMessage.swift | 13 -------- .../Unit/ErrorHandlingIntegrationTests.swift | 24 +++----------- 8 files changed, 6 insertions(+), 116 deletions(-) delete mode 100644 Sources/ElevenLabs/Public/Models/ReceivedMessage.swift delete mode 100644 Sources/ElevenLabs/Public/Models/SentMessage.swift diff --git a/Sources/ElevenLabs/Public/Conversation/Conversation.swift b/Sources/ElevenLabs/Public/Conversation/Conversation.swift index 8303e2b6..cf51b7ec 100644 --- a/Sources/ElevenLabs/Public/Conversation/Conversation.swift +++ b/Sources/ElevenLabs/Public/Conversation/Conversation.swift @@ -394,21 +394,6 @@ public final class Conversation: ObservableObject { pendingToolCalls.removeAll { $0.toolCallId == toolResult.toolCallId } } - @available(*, deprecated, message: "Use the Encodable overload; the Any overload can send a result the agent can't parse.") - public func sendToolResult( - for toolCallId: String, - result: Any, - isError: Bool = false, - errorType: ClientToolErrorType? = nil - ) async throws { - guard state.isActive else { throw ConversationError.notConnected } - let toolResult = try ClientToolResultEvent( - toolCallId: toolCallId, result: result, isError: isError, errorType: errorType - ) - try await publish(.clientToolResult(toolResult)) - pendingToolCalls.removeAll { $0.toolCallId == toolResult.toolCallId } - } - /// Mark a tool call as completed without sending a result (for tools that don't expect responses). public func markToolCallCompleted(_ toolCallId: String) { pendingToolCalls.removeAll { $0.toolCallId == toolCallId } diff --git a/Sources/ElevenLabs/Public/Conversation/ConversationAgentReadyReport.swift b/Sources/ElevenLabs/Public/Conversation/ConversationAgentReadyReport.swift index 508ca2f2..ea439fcf 100644 --- a/Sources/ElevenLabs/Public/Conversation/ConversationAgentReadyReport.swift +++ b/Sources/ElevenLabs/Public/Conversation/ConversationAgentReadyReport.swift @@ -3,13 +3,7 @@ import Foundation public struct ConversationAgentReadyReport: Sendable, Equatable { public let elapsed: TimeInterval - @available(*, deprecated, message: "Ignored: startup now fails if the agent isn't ready (no grace timeout).") - public let viaGraceTimeout: Bool = false - - @available(*, deprecated, message: "Ignored: startup now fails (throws) if the agent isn't ready.") - public let timedOut: Bool = false - - public init(elapsed: TimeInterval, viaGraceTimeout _: Bool = false, timedOut _: Bool = false) { + public init(elapsed: TimeInterval) { self.elapsed = elapsed } } diff --git a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupConfiguration.swift b/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupConfiguration.swift index 906e6ff0..42c27975 100644 --- a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupConfiguration.swift +++ b/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupConfiguration.swift @@ -3,16 +3,8 @@ import Foundation public struct ConversationStartupConfiguration: Sendable, Equatable { public var agentReadyTimeout: TimeInterval - @available(*, deprecated, message: "Ignored: the conversation-init handshake is now sent once (no retries).") - public var initRetryDelays: [TimeInterval] = [0, 0.2, 0.5] - - @available(*, deprecated, message: "Ignored: startup now always fails if the agent isn't ready in time.") - public var failIfAgentNotReady: Bool = false - public init( - agentReadyTimeout: TimeInterval = 3.0, - initRetryDelays _: [TimeInterval] = [0, 0.2, 0.5], - failIfAgentNotReady _: Bool = false + agentReadyTimeout: TimeInterval = 3.0 ) { self.agentReadyTimeout = agentReadyTimeout } diff --git a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupMetrics.swift b/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupMetrics.swift index ceefa96b..7d917d9e 100644 --- a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupMetrics.swift +++ b/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupMetrics.swift @@ -6,12 +6,6 @@ public struct ConversationStartupMetrics: Sendable, Equatable { public var roomConnect: TimeInterval? public var agentReady: TimeInterval? - @available(*, deprecated, message: "Ignored: startup now fails if the agent isn't ready (no grace timeout).") - public var agentReadyViaGraceTimeout: Bool = false - - @available(*, deprecated, message: "Ignored: startup now fails (throws) if the agent isn't ready.") - public var agentReadyTimedOut: Bool = false - public var agentReadyBuffer: TimeInterval? public var conversationInit: TimeInterval? public var conversationInitAttempts: Int diff --git a/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift b/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift index 11fb78e7..def9de05 100644 --- a/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift +++ b/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift @@ -75,38 +75,6 @@ public struct ClientToolResultEvent: Sendable { public let isError: Bool public let errorType: ClientToolErrorType? - @available( - *, - deprecated, - message: "Use init(toolCallId:result:) with a String, or Conversation.sendToolResult with an Encodable value." - ) - public init( - toolCallId: String, - result: Any, - isError: Bool = false, - errorType: ClientToolErrorType? = nil - ) throws { - self.toolCallId = toolCallId - self.isError = isError || errorType != nil - self.errorType = errorType - - if let stringResult = result as? String { - self.result = stringResult - } else if JSONSerialization.isValidJSONObject(result) { - let jsonData = try JSONSerialization.data(withJSONObject: result) - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw NSError( - domain: "ClientToolResultEvent", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to convert result to JSON string"] - ) - } - self.result = jsonString - } else { - self.result = String(describing: result) - } - } - /// `result` is a simple string or a JSON string public init( toolCallId: String, diff --git a/Sources/ElevenLabs/Public/Models/ReceivedMessage.swift b/Sources/ElevenLabs/Public/Models/ReceivedMessage.swift deleted file mode 100644 index 9f0e0a95..00000000 --- a/Sources/ElevenLabs/Public/Models/ReceivedMessage.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -/// A message received from the agent. -@available(*, deprecated, message: "No longer used by the SDK. Observe `Conversation.messages` instead. Will be removed in 4.0.") -public struct ReceivedMessage: Identifiable, Equatable, Sendable { - public let id: String - public let timestamp: Date - public let content: Content - - public enum Content: Equatable, Sendable { - case agentTranscript(String) - case userTranscript(String) - } -} diff --git a/Sources/ElevenLabs/Public/Models/SentMessage.swift b/Sources/ElevenLabs/Public/Models/SentMessage.swift deleted file mode 100644 index 96d6ef65..00000000 --- a/Sources/ElevenLabs/Public/Models/SentMessage.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -/// A message sent to the agent. -@available(*, deprecated, message: "No longer used by the SDK. Observe `Conversation.messages` instead. Will be removed in 4.0.") -public struct SentMessage: Identifiable, Equatable, Sendable { - public let id: String - public let timestamp: Date - public let content: Content - - public enum Content: Equatable, Sendable { - case userText(String) - } -} diff --git a/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift b/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift index 7d93d710..c95b0dd8 100644 --- a/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift +++ b/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift @@ -89,11 +89,7 @@ final class ErrorHandlingIntegrationTests: XCTestCase { let collector = ErrorCollector() // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration( - agentReadyTimeout: 10.0, // Increased from default 3.0 - initRetryDelays: [0, 0.5, 1.0, 2.0], // More retry attempts - failIfAgentNotReady: false - ) + let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) // Increased from default 3.0 // Use automatic network strategy for faster test connections (allows all connection types) let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) @@ -169,11 +165,7 @@ final class ErrorHandlingIntegrationTests: XCTestCase { let collector = ErrorCollector() // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration( - agentReadyTimeout: 10.0, - initRetryDelays: [0, 0.5, 1.0, 2.0], - failIfAgentNotReady: false - ) + let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) // Use automatic network strategy for faster test connections let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) @@ -227,11 +219,7 @@ final class ErrorHandlingIntegrationTests: XCTestCase { print("\n๐Ÿงช Testing rapid connection attempts...") // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration( - agentReadyTimeout: 10.0, - initRetryDelays: [0, 0.5, 1.0, 2.0], - failIfAgentNotReady: false - ) + let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) // Use automatic network strategy for faster test connections let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) @@ -435,11 +423,7 @@ extension ErrorHandlingIntegrationTests { let errorCollector = ErrorCollector() // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration( - agentReadyTimeout: 10.0, - initRetryDelays: [0, 0.5, 1.0, 2.0], - failIfAgentNotReady: false - ) + let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) // Use automatic network strategy for faster test connections let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) From 09ccf9388dbe00aa40c6391d0a040f04412c0e88 Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:12:55 +0100 Subject: [PATCH 2/8] feat(events): parse client_error, default expectsResponse, drop dead event types - EventParser: drop internal_tentative_agent_response and asr_initiation_metadata parsing (server no longer needs them handled; now silently ignored alongside other known-but-unhandled event types instead of throwing), default expects_response to false when absent, and fall back to top-level code/message when client_error lacks an error_event sub-object. - IncomingEvents: remove the now-dead TentativeAgentResponseEvent/ ASRInitiationMetadataEvent types and .tentativeAgentResponse/ .asrInitiationMetadata cases; ClientToolCallEvent.expectsResponse defaults to false. - EndReason: drop .agentNotConnected (no longer distinguished from .remoteDisconnected). - Conversation+Events.swift: minimal compatibility edit dropping the two switch cases for the removed IncomingEvent cases above (the rest of this file's rewrite lands in the core-session-rewrite PR, which also needs to land together with Message.isPartial -- adding that field while this file's message-store logic is still unmigrated was verified to cause runtime data corruption in unrelated codepaths, so it's deferred there instead of included here). Note: EventSerializer.swift and OutgoingEvents.swift are NOT included here despite being conceptually "event wire protocol" -- their target versions require ConversationConfig.textOnly, DynamicVariableValue, and ConversationError.invalidToolResult, which don't exist until the core-session-rewrite PR. They land there instead. --- .../Conversation/Conversation+Events.swift | 7 ------- .../Internal/Utilities/EventParser.swift | 21 +++++++------------ .../Conversation/Models/EndReason.swift | 1 - .../ElevenLabs/Events/IncomingEvents.swift | 16 -------------- .../Unit/EventParserTests.swift | 16 ++++++++++++++ 5 files changed, 23 insertions(+), 38 deletions(-) diff --git a/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift b/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift index 913d8128..9b4f4335 100644 --- a/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift +++ b/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift @@ -12,9 +12,6 @@ extension Conversation { agentStateManager?.processSignal(.userTranscript) options.onUserTranscript?(e.transcript, e.eventId) - case .tentativeAgentResponse: - agentStateManager?.processSignal(.agentResponse) - case let .agentResponse(e): upsertAgentMessage(content: e.response, eventId: e.eventId) lastAgentEventId = e.eventId @@ -102,10 +99,6 @@ extension Conversation { // Update MCP connection status mcpConnectionStatus = status - case .asrInitiationMetadata: - // ASR initiation metadata is available in the event stream - break - case let .error(errorEvent): logger.error("Received error event from server: code=\(errorEvent.code), message=\(errorEvent.message ?? "none")") options.onError?(.serverError(errorEvent)) diff --git a/Sources/ElevenLabs/Internal/Utilities/EventParser.swift b/Sources/ElevenLabs/Internal/Utilities/EventParser.swift index d85807eb..3e0372c6 100644 --- a/Sources/ElevenLabs/Internal/Utilities/EventParser.swift +++ b/Sources/ElevenLabs/Internal/Utilities/EventParser.swift @@ -96,13 +96,6 @@ enum EventParser { return .vadScore(VadScoreEvent(vadScore: vadScore)) } - case "internal_tentative_agent_response": - if let event = json["tentative_agent_response_internal_event"] as? [String: Any], - let response = event["tentative_agent_response"] as? String - { - return .tentativeAgentResponse(TentativeAgentResponseEvent(tentativeResponse: response)) - } - case "conversation_initiation_metadata": if let event = json["conversation_initiation_metadata_event"] as? [String: Any], let conversationId = event["conversation_id"] as? String, @@ -250,13 +243,6 @@ enum EventParser { return .mcpConnectionStatus(MCPConnectionStatusEvent(integrations: integrations)) } - case "asr_initiation_metadata": - if let event = json["asr_initiation_metadata_event"] as? [String: Any], - let metadataData = try? JSONSerialization.data(withJSONObject: event) - { - return .asrInitiationMetadata(ASRInitiationMetadataEvent(metadataData: metadataData)) - } - case "agent_chat_response_part": if let event = json["text_response_part"] as? [String: Any], let text = event["text"] as? String, @@ -276,6 +262,13 @@ enum EventParser { let errorName = event?["error_name"] as? String return .error(ErrorEvent(code: code, message: message, errorName: errorName)) + // Known event types we intentionally don't surface to consumers. + case "agent_response_complete", + "guardrail_triggered", + "agent_tool_response_full_payload", + "asr_initiation_metadata": + return nil + default: throw EventParseError.unknownEventType(type) } diff --git a/Sources/ElevenLabs/Public/Conversation/Models/EndReason.swift b/Sources/ElevenLabs/Public/Conversation/Models/EndReason.swift index 1e8465f8..aca4d528 100644 --- a/Sources/ElevenLabs/Public/Conversation/Models/EndReason.swift +++ b/Sources/ElevenLabs/Public/Conversation/Models/EndReason.swift @@ -2,6 +2,5 @@ import Foundation public enum EndReason: Equatable, Sendable { case userEnded - case agentNotConnected case remoteDisconnected } diff --git a/Sources/ElevenLabs/Public/ElevenLabs/Events/IncomingEvents.swift b/Sources/ElevenLabs/Public/ElevenLabs/Events/IncomingEvents.swift index 5113a04d..7705ef5b 100644 --- a/Sources/ElevenLabs/Public/ElevenLabs/Events/IncomingEvents.swift +++ b/Sources/ElevenLabs/Public/ElevenLabs/Events/IncomingEvents.swift @@ -13,7 +13,6 @@ public enum IncomingEvent: Sendable { case audio(AudioEvent) case interruption(InterruptionEvent) case vadScore(VadScoreEvent) - case tentativeAgentResponse(TentativeAgentResponseEvent) case conversationMetadata(ConversationMetadataEvent) case ping(PingEvent) case clientToolCall(ClientToolCallEvent) @@ -21,7 +20,6 @@ public enum IncomingEvent: Sendable { case agentToolResponse(AgentToolResponseEvent) case mcpToolCall(MCPToolCallEvent) case mcpConnectionStatus(MCPConnectionStatusEvent) - case asrInitiationMetadata(ASRInitiationMetadataEvent) case error(ErrorEvent) } @@ -87,11 +85,6 @@ public struct InterruptionEvent: Sendable { public let eventId: Int } -/// Tentative agent response (before finalization) -public struct TentativeAgentResponseEvent: Sendable { - public let tentativeResponse: String -} - /// Conversation initialization metadata public struct ConversationMetadataEvent: Sendable { public let conversationId: String @@ -197,15 +190,6 @@ public struct MCPConnectionStatusEvent: Sendable { public let integrations: [Integration] } -/// ASR initiation metadata event -public struct ASRInitiationMetadataEvent: Sendable { - public let metadataData: Data - - public func getMetadata() throws -> [String: Any] { - try JSONSerialization.jsonObject(with: metadataData) as? [String: Any] ?? [:] - } -} - /// Server error event with code, optional name, and message. public struct ErrorEvent: Sendable, Equatable { public let code: Int diff --git a/Tests/ElevenLabsTests/Unit/EventParserTests.swift b/Tests/ElevenLabsTests/Unit/EventParserTests.swift index 7da44a7c..2c9b6867 100644 --- a/Tests/ElevenLabsTests/Unit/EventParserTests.swift +++ b/Tests/ElevenLabsTests/Unit/EventParserTests.swift @@ -351,6 +351,22 @@ final class EventParserTests: XCTestCase { XCTAssertEqual(metadata.eventId, 456) XCTAssertFalse(metadata.metadataData.isEmpty) } + + func testKnownButIgnoredEventTypesParseToNil() throws { + let payloads = [ + #"{"type":"agent_response_complete","agent_response_complete_event":{"event_id":42}}"#, + #"{"type":"guardrail_triggered","guardrail_triggered_event":{"guardrail_name":"toxicity"}}"#, + #"{"type":"agent_tool_response_full_payload","agent_tool_response_full_payload":{"tool_name":"search","tool_call_id":"abc","tool_type":"system","is_error":false,"event_id":7,"is_called":true,"full_tool_result":"x"}}"#, + #"{"type":"asr_initiation_metadata","asr_initiation_metadata_event":{"metadata":{}}}"# + ] + + for payload in payloads { + let json = try XCTUnwrap(payload.data(using: .utf8)) + // Intentionally ignored: parsed to nil (dropped), never thrown as unknown. + let event = try EventParser.parseIncomingEvent(from: json) + XCTAssertNil(event, "Expected \(payload) to be ignored (nil)") + } + } } // swiftlint:enable line_length force_unwrapping From 03c74800a87615a5e5e4b470ae457813c2374c7b Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:29:12 +0100 Subject: [PATCH 3/8] feat(config): introduce ConversationAuth/Config/Callbacks/state primitives Replaces the ConversationOptions-era config/error/state shape with the v4 primitives that the rest of this session rewrite builds on: - ConversationAuth (new) replaces ElevenLabsConfiguration's auth cases (publicAgent/conversationToken/signedWebSocketURL); the .customTokenProvider case and participantName/agentId fields have no replacement. - ElevenLabsEndpoints (new) makes API/WebSocket endpoints configurable per-session instead of hardcoded (see ConnectionConstants removal). - ConversationConfig absorbs ConversationOptions' non-callback fields: new LogLevel/DynamicVariableValue types, relayOnly: Bool (replacing the deleted LiveKitNetworkConfiguration's 3-way ICE strategy + custom ICE servers -- those are no longer configurable), agentJoinTimeout/ conversationInitTimeout (replacing ConversationStartupConfiguration). - ConversationCallbacks (new) holds every onXxx closure split out of ConversationOptions. Dropped with no replacement: onCanSendFeedbackChange, onSpeechActivity, onConversationMetadata (now a published property instead), onAgentStateChange/onStartupStateChange (superseded by published agentState/state). - ConversationError gains .initializationTimeout, .microphonePermissionDenied, .invalidToolResult, .invalidURL, and a recoverable .underlyingError; drops .localNetworkPermissionRequired (see LocalNetworkPermissionMonitor removal) and .noSoftwareMuteHandlerConfigured. - ConversationState replaces .active(CallInfo) with .connected (the connected agent id is no longer retrievable from state at all -- CallInfo is deleted), .error(ConversationError) with .startupFailed(...), and adds StartupPhase for the new .connecting(phase:) case (no more startup-timing telemetry -- ConversationAgentReadyReport/StartupMetrics/StartupState/ StartupResult are all deleted). - AgentState is un-nested from ElevenLabs.AgentState to a top-level type (old nested one stays for now, removed with ElevenLabs.swift later in this stack). --- .../Internal/Conversation/StartupResult.swift | 28 -- .../Configuration/ConversationAuth.swift | 43 +++ .../Configuration/ElevenLabsEndpoints.swift | 80 +++++ .../Public/Conversation/AgentState.swift | 11 + .../Conversation/ConversationConfig.swift | 332 +++++++++--------- .../Conversation/ConversationError.swift | 69 +++- .../Conversation/ConversationState.swift | 69 +++- .../Startup/ConversationStartupFailure.swift | 19 +- .../Startup/ConversationStartupState.swift | 12 - .../Public/Events/ConversationCallbacks.swift | 99 ++++++ .../LiveKit/LiveKitNetworkConfiguration.swift | 70 ---- .../Unit/ConversationConfigTests.swift | 16 +- 12 files changed, 547 insertions(+), 301 deletions(-) delete mode 100644 Sources/ElevenLabs/Internal/Conversation/StartupResult.swift create mode 100644 Sources/ElevenLabs/Public/Configuration/ConversationAuth.swift create mode 100644 Sources/ElevenLabs/Public/Configuration/ElevenLabsEndpoints.swift create mode 100644 Sources/ElevenLabs/Public/Conversation/AgentState.swift delete mode 100644 Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupState.swift create mode 100644 Sources/ElevenLabs/Public/Events/ConversationCallbacks.swift delete mode 100644 Sources/ElevenLabs/Public/LiveKit/LiveKitNetworkConfiguration.swift diff --git a/Sources/ElevenLabs/Internal/Conversation/StartupResult.swift b/Sources/ElevenLabs/Internal/Conversation/StartupResult.swift deleted file mode 100644 index 93e152ae..00000000 --- a/Sources/ElevenLabs/Internal/Conversation/StartupResult.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -struct StartupResult { - let agentId: String - let metrics: ConversationStartupMetrics -} - -struct StartupFailure: Error { - let reason: ConversationStartupFailure - let error: ConversationError - let metrics: ConversationStartupMetrics - - static func token(_ error: ConversationError, _ metrics: ConversationStartupMetrics) -> Self { - .init(reason: .token(error), error: error, metrics: metrics) - } - - static func room(_ error: ConversationError, _ metrics: ConversationStartupMetrics) -> Self { - .init(reason: .room(error), error: error, metrics: metrics) - } - - static func agentTimeout(_ metrics: ConversationStartupMetrics) -> Self { - .init(reason: .agentTimeout, error: .agentTimeout, metrics: metrics) - } - - static func conversationInit(_ error: ConversationError, _ metrics: ConversationStartupMetrics) -> Self { - .init(reason: .conversationInit(error), error: error, metrics: metrics) - } -} diff --git a/Sources/ElevenLabs/Public/Configuration/ConversationAuth.swift b/Sources/ElevenLabs/Public/Configuration/ConversationAuth.swift new file mode 100644 index 00000000..1b009d8e --- /dev/null +++ b/Sources/ElevenLabs/Public/Configuration/ConversationAuth.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Configuration for ElevenLabs conversational AI +public struct ConversationAuth: Sendable { + /// The source of authentication for the conversation + public enum AuthSource: Sendable { + /// Use a public agent ID (no authentication required) + case publicAgentId(String) + /// Use a conversation token from your backend + case conversationToken(String) + /// Use a signed WebSocket URL from your backend (agent ID parsed from the URL) + case signedWebSocketURL(url: String, agentId: String) + } + + public let authSource: AuthSource + + /// Initialize with a public agent ID + public static func publicAgent(id: String) -> Self { + .init(authSource: .publicAgentId(id)) + } + + /// Initialize with a conversation token + public static func conversationToken(_ token: String) -> Self { + .init(authSource: .conversationToken(token)) + } + + /// Initialize with a signed WebSocket URL generated by your backend. + /// Throws if the URL is missing the required `agent_id` query parameter. + public static func signedWebSocketURL(_ url: String) throws -> Self { + guard + let agentId = URLComponents(string: url)? + .queryItems? + .first(where: { $0.name == "agent_id" })? + .value, + !agentId.isEmpty + else { + throw ConversationError.authenticationFailed( + "Signed WebSocket URL is missing the agent_id query parameter." + ) + } + return .init(authSource: .signedWebSocketURL(url: url, agentId: agentId)) + } +} diff --git a/Sources/ElevenLabs/Public/Configuration/ElevenLabsEndpoints.swift b/Sources/ElevenLabs/Public/Configuration/ElevenLabsEndpoints.swift new file mode 100644 index 00000000..60ef2da1 --- /dev/null +++ b/Sources/ElevenLabs/Public/Configuration/ElevenLabsEndpoints.swift @@ -0,0 +1,80 @@ +import Foundation + +/// The set of network endpoints the SDK talks to. +/// +/// Pass a custom value when you front ElevenLabs through a proxy/gateway, use a +/// regional/data-residency host, or point at a staging deployment. Defaults to +/// ``production``. +/// +/// Two of the three endpoints (`textWebSocket`, `apiBase`) live on the same API +/// host, while `voiceWebSocket` (LiveKit signaling) is a separate host. The +/// conversation-token endpoint used for voice connections is derived from +/// `apiBase` by ``TokenService``. For the common "everything behind one API +/// host" case use ``apiBase(_:voiceWebSocket:)``, which derives the API-host +/// endpoints from a single base URL; use the memberwise initializer when you +/// need to override individual endpoints (e.g. a custom LiveKit host). +public struct ElevenLabsEndpoints: Sendable, Equatable { + /// LiveKit signaling endpoint used for voice conversations. + public var voiceWebSocket: URL + /// WebSocket endpoint used for text-only conversations. + public var textWebSocket: URL + /// Base host for conversation-scoped REST endpoints (file upload/delete, + /// post-call feedback). + public var apiBase: URL + + /// Override individual endpoints. Any omitted endpoint falls back to its + /// ``production`` value. + public init( + voiceWebSocket: URL = ElevenLabsEndpoints.production.voiceWebSocket, + textWebSocket: URL = ElevenLabsEndpoints.production.textWebSocket, + apiBase: URL = ElevenLabsEndpoints.production.apiBase + ) { + self.voiceWebSocket = voiceWebSocket + self.textWebSocket = textWebSocket + self.apiBase = apiBase + } + + /// The default ElevenLabs production endpoints. + public static let production = ElevenLabsEndpoints( + voiceWebSocket: URL(string: "wss://livekit.rtc.elevenlabs.io")!, + textWebSocket: URL(string: "wss://api.elevenlabs.io/v1/convai/conversation")!, + apiBase: URL(string: "https://api.elevenlabs.io")! + ) + + /// Route the API-host endpoints (`textWebSocket`, `apiBase`) through a + /// single base URL, deriving the text-WebSocket path from it. The text + /// endpoint reuses `apiBaseURL`'s host with the scheme upgraded to + /// `ws`/`wss`. + /// + /// - Parameters: + /// - apiBaseURL: Base URL of your API host, e.g. `https://my-proxy.example.com`. + /// - voiceWebSocket: LiveKit signaling host. Defaults to ``production``'s, + /// since LiveKit normally lives on a separate host from the API. + public static func apiBase( + _ apiBaseURL: URL, + voiceWebSocket: URL = ElevenLabsEndpoints.production.voiceWebSocket + ) -> ElevenLabsEndpoints { + ElevenLabsEndpoints( + voiceWebSocket: voiceWebSocket, + textWebSocket: webSocketURL(from: apiBaseURL).appendingPathComponent("v1/convai/conversation"), + apiBase: apiBaseURL + ) + } + + /// Returns `url` with its scheme upgraded to the WebSocket equivalent + /// (`http` โ†’ `ws`, `https`/unknown โ†’ `wss`); leaves `ws`/`wss` untouched. + private static func webSocketURL(from url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + switch components.scheme?.lowercased() { + case "ws", "wss": + break + case "http": + components.scheme = "ws" + default: + components.scheme = "wss" + } + return components.url ?? url + } +} diff --git a/Sources/ElevenLabs/Public/Conversation/AgentState.swift b/Sources/ElevenLabs/Public/Conversation/AgentState.swift new file mode 100644 index 00000000..35625c1a --- /dev/null +++ b/Sources/ElevenLabs/Public/Conversation/AgentState.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Agent state indicating what the agent is currently doing. +public enum AgentState: Sendable, Equatable { + /// Agent is listening to the user + case listening + /// Agent is speaking + case speaking + /// Agent is thinking (e.g. preparing a tool call) + case thinking +} diff --git a/Sources/ElevenLabs/Public/Conversation/ConversationConfig.swift b/Sources/ElevenLabs/Public/Conversation/ConversationConfig.swift index dd8a8811..a15367b1 100644 --- a/Sources/ElevenLabs/Public/Conversation/ConversationConfig.swift +++ b/Sources/ElevenLabs/Public/Conversation/ConversationConfig.swift @@ -1,152 +1,223 @@ import Foundation -import LiveKit /// Reason for the conversation disconnection public enum DisconnectionReason: Sendable { case agent case user - case error } -/// Main configuration for a conversation session -public struct ConversationConfig: Sendable { - public var agentOverrides: AgentOverrides? - public var ttsOverrides: TTSOverrides? - public var conversationOverrides: ConversationOverrides? - public var customLlmExtraBody: [String: String]? // Simplified to be Sendable - public var dynamicVariables: [String: String]? // Simplified to be Sendable - public var userId: String? - /// Optional environment for the agent (defaults to production when nil) - public var environment: String? +extension DisconnectionReason { + /// The coarse disconnection reason for an ``EndReason``. Keeps the two + /// representations in sync from a single source so callers can use either. + init(_ endReason: EndReason) { + switch endReason { + case .userEnded: self = .user + case .remoteDisconnected: self = .agent + } + } +} - /// Called when the agent is ready and the conversation can begin - public var onAgentReady: (@Sendable () -> Void)? +/// Determines how microphone setup failures are handled during connection +public enum MicrophoneFailureHandling: Sendable { + /// Throw an error if microphone setup fails (recommended for voice-first apps) + case throwError + /// Log a warning but continue without microphone (useful for fallback scenarios) + case continueWithoutMicrophone +} - /// Called when the agent disconnects or the conversation ends - public var onDisconnect: (@Sendable (DisconnectionReason) -> Void)? +/// Logging level for the SDK's internal diagnostics. +/// +/// Top-level in the `ElevenLabs` module, so it can be referred to as `LogLevel` +/// or, if that name collides in your code, as `ElevenLabs.LogLevel`. Set it via +/// `ConversationConfig.logLevel`. +public enum LogLevel: Int, Comparable, Sendable { + case error = 0 + case warning = 1 + case info = 2 + case debug = 3 + case trace = 4 + /// Logs the SDK's own diagnostics at ``debug`` verbosity and *additionally* + /// forwards LiveKit + the underlying WebRTC logs (ICE servers, candidate + /// gathering, TURN allocation). Extremely noisy โ€” use only when diagnosing + /// transport/connectivity issues such as ICE or relay failures. + case debugWithRTC = 5 + + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } - /// Called whenever the startup state transitions - public var onStartupStateChange: (@Sendable (ConversationStartupState) -> Void)? + /// The SDK-log threshold this level maps to. ``debugWithRTC`` keeps the + /// SDK's own output at ``debug`` level; the WebRTC firehose is separate. + var sdkVerbosity: LogLevel { + self == .debugWithRTC ? .debug : self + } - /// Controls timings and retry behavior for the initialization handshake - public var startupConfiguration: ConversationStartupConfiguration + /// Whether this level forwards LiveKit + WebRTC logs. + var forwardsRTCLogs: Bool { + self == .debugWithRTC + } +} - /// Controls microphone pipeline behaviour and VAD callbacks. - public var audioConfiguration: AudioPipelineConfiguration? +/// A single `dynamic_variables` value. Mirrors the value types the ConvAI API +/// accepts for dynamic variables โ€” string, integer, number, boolean, array, or +/// null. Nested objects are intentionally unsupported: dynamic variables are +/// flat placeholders substituted into the prompt, first message, and tool +/// parameters, and the API does not document object values for them. +public enum DynamicVariableValue: Sendable, Equatable { + case string(String) + case integer(Int) + case number(Double) + case bool(Bool) + case array([DynamicVariableValue]) + case null + + var jsonObject: Any { + switch self { + case let .string(value): + return value + case let .integer(value): + return value + case let .number(value): + return value + case let .bool(value): + return value + case let .array(values): + return values.map(\.jsonObject) + case .null: + return NSNull() + } + } +} - /// Controls LiveKit peer connection behaviour, including ICE policies. - public var networkConfiguration: LiveKitNetworkConfiguration +extension DynamicVariableValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension DynamicVariableValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .integer(value) + } +} - /// Called when a startup-related error occurs - public var onError: (@Sendable (ConversationError) -> Void)? +extension DynamicVariableValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} - /// Called when LiveKit detects speech activity while muted. - public var onSpeechActivity: (@Sendable (SpeechActivityEvent) -> Void)? +extension DynamicVariableValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} - /// Called for each agent response with the associated event identifier. - public var onAgentResponse: (@Sendable (_ text: String, _ eventId: Int) -> Void)? +extension DynamicVariableValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: DynamicVariableValue...) { + self = .array(elements) + } +} - /// Called when an agent response correction is received. - public var onAgentResponseCorrection: (@Sendable (_ original: String, _ corrected: String, _ eventId: Int) -> Void)? +extension DynamicVariableValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} - /// Called when agent response metadata is received. - public var onAgentResponseMetadata: (@Sendable (_ metadataData: Data, _ eventId: Int) -> Void)? +/// Main configuration for a conversation session +public struct ConversationConfig: Sendable { + public var agentOverrides: AgentOverrides? + public var ttsOverrides: TTSOverrides? - /// Called for each user transcript event. - public var onUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? + /// Run the conversation in text-only mode: no microphone or audio pipeline, + /// connecting over the text WebSocket transport instead of WebRTC. Sent to the + /// server as `conversation_config_override.conversation.text_only` when `true`. + public var textOnly: Bool - /// Called when conversation metadata arrives. - public var onConversationMetadata: (@Sendable (ConversationMetadataEvent) -> Void)? + public var dynamicVariables: [String: DynamicVariableValue]? + public var userId: String? + /// Optional environment for the agent (defaults to production when nil). + /// + /// Applied to the requests the SDK originates โ€” the conversation-token + /// exchange, the public-agent WebSocket URL, and conversation REST calls. It + /// is deliberately **not** applied to a caller-supplied + /// ``ConversationAuth/signedWebSocketURL``, which is used verbatim because the + /// signed URL already encodes its own environment/region. + public var environment: String? - /// Called when the agent emits a tool response event. - public var onAgentToolResponse: (@Sendable (AgentToolResponseEvent) -> Void)? + /// Network endpoints used for this conversation's connections and REST calls + /// (token exchange, voice/text WebSockets, file upload/feedback). Override to + /// front the SDK through a proxy, regional host, or staging deployment. + public var endpoints: ElevenLabsEndpoints - /// Called when the agent requests a tool execution. - public var onAgentToolRequest: (@Sendable (AgentToolRequestEvent) -> Void)? + /// How to handle microphone setup failures during connection + public var microphoneFailureHandling: MicrophoneFailureHandling - /// Called when the agent detects an interruption. - public var onInterruption: (@Sendable (_ eventId: Int) -> Void)? + /// Maximum time to wait for the agent (the remote participant) to join the + /// LiveKit room during voice startup. Voice only. Exceeding it fails startup + /// with `.agentTimeout`. Defaults to 3 seconds. + public var agentJoinTimeout: TimeInterval - /// Called whenever a VAD score is emitted. - public var onVadScore: (@Sendable (_ score: Double) -> Void)? + /// Maximum time to wait for the server to acknowledge the + /// `conversation_initiation_client_data` handshake with + /// `conversation_initiation_metadata`, which completes startup. Applies to + /// both voice and text. Exceeding it fails startup with `.initializationTimeout`. + /// Defaults to 3 seconds. + public var conversationInitTimeout: TimeInterval - /// Called when audio alignment metadata is emitted. - public var onAudioAlignment: (@Sendable (AudioAlignment) -> Void)? + /// Controls microphone pipeline behaviour and VAD callbacks. + public var audioConfiguration: AudioPipelineConfiguration? - /// Called when feedback availability changes. - public var onCanSendFeedbackChange: (@Sendable (Bool) -> Void)? + /// Force TURN-relay-only ICE for the voice peer connection. Skips host + /// candidate gathering, which avoids the iOS local-network permission + /// prompt, at the cost of always relaying media through TURN. Defaults to + /// `false` (gather all candidate types). + public var relayOnly: Bool - /// Called when a client tool call is received without a registered handler. - public var onUnhandledClientToolCall: (@Sendable (ClientToolCallEvent) -> Void)? + /// Verbosity of the SDK's internal diagnostics (emitted via `os.Logger`), + /// fixed for the lifetime of the conversation this config starts. Defaults to + /// `.warning`. + public var logLevel: LogLevel - /// When provided, agent state is computed from VAD scores and protocol events - /// instead of relying on LiveKit's isSpeaking detection. + /// Opt-in event-based agent-state tracking (VAD + client events). When set, + /// `ConversationClient.agentState` is driven by this heuristic; when `nil`, + /// agent state is derived from the transport's speaking detection. public var agentStateConfiguration: AgentStateConfiguration? - /// Called whenever the agent state changes (event-based mode only). - public var onAgentStateChange: (@Sendable (ElevenLabs.AgentState) -> Void)? - public init( agentOverrides: AgentOverrides? = nil, ttsOverrides: TTSOverrides? = nil, - conversationOverrides: ConversationOverrides? = nil, - customLlmExtraBody: [String: String]? = nil, - dynamicVariables: [String: String]? = nil, + textOnly: Bool = false, + dynamicVariables: [String: DynamicVariableValue]? = nil, userId: String? = nil, environment: String? = nil, - onAgentReady: (@Sendable () -> Void)? = nil, - onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil, - onStartupStateChange: (@Sendable (ConversationStartupState) -> Void)? = nil, - startupConfiguration: ConversationStartupConfiguration = .default, + endpoints: ElevenLabsEndpoints = .production, + microphoneFailureHandling: MicrophoneFailureHandling = .throwError, + agentJoinTimeout: TimeInterval = 3.0, + conversationInitTimeout: TimeInterval = 3.0, audioConfiguration: AudioPipelineConfiguration? = nil, - networkConfiguration: LiveKitNetworkConfiguration = .default, - onError: (@Sendable (ConversationError) -> Void)? = nil, - onSpeechActivity: (@Sendable (SpeechActivityEvent) -> Void)? = nil, - onAgentResponse: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil, - onAgentResponseCorrection: (@Sendable (_ original: String, _ corrected: String, _ eventId: Int) -> Void)? = nil, - onAgentResponseMetadata: (@Sendable (_ metadataData: Data, _ eventId: Int) -> Void)? = nil, - onUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil, - onConversationMetadata: (@Sendable (ConversationMetadataEvent) -> Void)? = nil, - onAgentToolResponse: (@Sendable (AgentToolResponseEvent) -> Void)? = nil, - onAgentToolRequest: (@Sendable (AgentToolRequestEvent) -> Void)? = nil, - onInterruption: (@Sendable (_ eventId: Int) -> Void)? = nil, - onVadScore: (@Sendable (_ score: Double) -> Void)? = nil, - onAudioAlignment: (@Sendable (AudioAlignment) -> Void)? = nil, - onCanSendFeedbackChange: (@Sendable (Bool) -> Void)? = nil, - onUnhandledClientToolCall: (@Sendable (ClientToolCallEvent) -> Void)? = nil, - agentStateConfiguration: AgentStateConfiguration? = nil, - onAgentStateChange: (@Sendable (ElevenLabs.AgentState) -> Void)? = nil + relayOnly: Bool = false, + logLevel: LogLevel = .warning, + agentStateConfiguration: AgentStateConfiguration? = nil ) { self.agentOverrides = agentOverrides self.ttsOverrides = ttsOverrides - self.conversationOverrides = conversationOverrides - self.customLlmExtraBody = customLlmExtraBody + self.textOnly = textOnly self.dynamicVariables = dynamicVariables self.userId = userId self.environment = environment - self.onAgentReady = onAgentReady - self.onDisconnect = onDisconnect - self.onStartupStateChange = onStartupStateChange - self.startupConfiguration = startupConfiguration + self.endpoints = endpoints + self.microphoneFailureHandling = microphoneFailureHandling + self.agentJoinTimeout = agentJoinTimeout + self.conversationInitTimeout = conversationInitTimeout self.audioConfiguration = audioConfiguration - self.networkConfiguration = networkConfiguration - self.onError = onError - self.onSpeechActivity = onSpeechActivity - self.onAgentResponse = onAgentResponse - self.onAgentResponseCorrection = onAgentResponseCorrection - self.onAgentResponseMetadata = onAgentResponseMetadata - self.onUserTranscript = onUserTranscript - self.onConversationMetadata = onConversationMetadata - self.onAgentToolResponse = onAgentToolResponse - self.onAgentToolRequest = onAgentToolRequest - self.onInterruption = onInterruption - self.onVadScore = onVadScore - self.onAudioAlignment = onAudioAlignment - self.onCanSendFeedbackChange = onCanSendFeedbackChange - self.onUnhandledClientToolCall = onUnhandledClientToolCall + self.relayOnly = relayOnly + self.logLevel = logLevel self.agentStateConfiguration = agentStateConfiguration - self.onAgentStateChange = onAgentStateChange } + + public static let `default` = ConversationConfig() } /// Agent behavior overrides @@ -184,57 +255,4 @@ public struct TTSOverrides: Sendable { self.speed = speed self.similarityBoost = similarityBoost } -} - -/// Conversation behavior overrides -public struct ConversationOverrides: Sendable { - public var textOnly: Bool - public var clientEvents: [String]? - - public init( - textOnly: Bool = false, - clientEvents: [String]? = nil - ) { - self.textOnly = textOnly - self.clientEvents = clientEvents - } -} - -// MARK: - Conversion Extension - -extension ConversationConfig { - /// Convert ConversationConfig to ConversationOptions for internal use - func toConversationOptions() -> ConversationOptions { - ConversationOptions( - conversationOverrides: conversationOverrides ?? ConversationOverrides(), - agentOverrides: agentOverrides, - ttsOverrides: ttsOverrides, - customLlmExtraBody: customLlmExtraBody, - dynamicVariables: dynamicVariables, - userId: userId, - environment: environment, - onAgentReady: onAgentReady, - onDisconnect: onDisconnect, - onStartupStateChange: onStartupStateChange, - startupConfiguration: startupConfiguration, - audioConfiguration: audioConfiguration, - networkConfiguration: networkConfiguration, - onError: onError, - onSpeechActivity: onSpeechActivity, - onAgentResponse: onAgentResponse, - onAgentResponseCorrection: onAgentResponseCorrection, - onAgentResponseMetadata: onAgentResponseMetadata, - onUserTranscript: onUserTranscript, - onConversationMetadata: onConversationMetadata, - onAgentToolResponse: onAgentToolResponse, - onAgentToolRequest: onAgentToolRequest, - onInterruption: onInterruption, - onVadScore: onVadScore, - onAudioAlignment: onAudioAlignment, - onCanSendFeedbackChange: onCanSendFeedbackChange, - onUnhandledClientToolCall: onUnhandledClientToolCall, - agentStateConfiguration: agentStateConfiguration, - onAgentStateChange: onAgentStateChange - ) - } -} +} \ No newline at end of file diff --git a/Sources/ElevenLabs/Public/Conversation/ConversationError.swift b/Sources/ElevenLabs/Public/Conversation/ConversationError.swift index d4f8b71f..86d10f90 100644 --- a/Sources/ElevenLabs/Public/Conversation/ConversationError.swift +++ b/Sources/ElevenLabs/Public/Conversation/ConversationError.swift @@ -3,34 +3,81 @@ import Foundation public enum ConversationError: LocalizedError, Sendable, Equatable { case notConnected case alreadyActive - case connectionFailed(String) // Store error description instead of Error for Equatable + /// A connection attempt failed. Carries a human-readable description (used for + /// display and `Equatable`) and, when the failure originated from a + /// system/transport error, the original error so callers can downcast it (e.g. + /// to `URLError`) and branch on the cause. Read it via ``underlyingError``. + case connectionFailed(String, UnderlyingError? = nil) case authenticationFailed(String) case agentTimeout - case microphoneToggleFailed(String) // Store error description instead of Error for Equatable - case localNetworkPermissionRequired - case noSoftwareMuteHandlerConfigured + /// The `conversation_initiation_client_data` handshake was not acknowledged + /// with `conversation_initiation_metadata` within `conversationInitTimeout`. + /// Distinct from `agentTimeout` (the voice room-join wait) and applies to + /// both voice and text-only startup. + case initializationTimeout + /// Toggling the microphone failed. Carries a human-readable description and, + /// when available, the original underlying error (read via ``underlyingError``). + case microphoneToggleFailed(String, UnderlyingError? = nil) + /// Microphone permission is denied/restricted and iOS will no longer prompt. + /// The user must re-enable it from Settings. + case microphonePermissionDenied case serverError(ErrorEvent) + /// A client tool result value could not be encoded. It was neither a + /// `String`, a JSON object/array, nor a numeric/boolean scalar โ€” return one + /// of those instead of an arbitrary type. The associated value names the + /// offending type. + case invalidToolResult(String) + /// Failed to build a valid request URL. + case invalidURL - /// Helper methods to create errors with Error types + /// Wrap a system/transport `Error`, preserving both its localized description + /// (for display + `Equatable`) and the original error (for programmatic + /// inspection via ``underlyingError``). public static func connectionFailed(_ error: Error) -> ConversationError { - .connectionFailed(error.localizedDescription) + .connectionFailed(error.localizedDescription, UnderlyingError(error)) } public static func microphoneToggleFailed(_ error: Error) -> ConversationError { - .microphoneToggleFailed(error.localizedDescription) + .microphoneToggleFailed(error.localizedDescription, UnderlyingError(error)) } public var errorDescription: String? { switch self { case .notConnected: "Conversation is not connected." case .alreadyActive: "Conversation is already active." - case let .connectionFailed(description): "Connection failed: \(description)" + case let .connectionFailed(description, _): "Connection failed: \(description)" case let .authenticationFailed(msg): "Authentication failed: \(msg)" case .agentTimeout: "Agent did not join in time." - case let .microphoneToggleFailed(description): "Failed to toggle microphone: \(description)" - case .localNetworkPermissionRequired: "Local Network permission is required." + case .initializationTimeout: "The conversation initialization handshake was not acknowledged in time." + case let .microphoneToggleFailed(description, _): "Failed to toggle microphone: \(description)" + case .microphonePermissionDenied: "Microphone access is off. Enable it in Settings to start a voice conversation." case let .serverError(event): "Server error (\(event.code)): \(event.message ?? "unknown")" - case .noSoftwareMuteHandlerConfigured: "No software mute handler is configured." + case let .invalidToolResult(detail): "Invalid tool result: \(detail)" + case .invalidURL: "Could not build a valid request URL." } } + + /// The original system/transport error behind a wrapped failure, if one was + /// captured. Non-`nil` only for ``connectionFailed`` and + /// ``microphoneToggleFailed`` values built from an `Error`. Downcast it to a + /// concrete type (e.g. `URLError`) to branch on the underlying cause instead + /// of string-matching the (localized) description. + public var underlyingError: (any Error)? { + switch self { + case let .connectionFailed(_, cause), let .microphoneToggleFailed(_, cause): + cause?.error + default: + nil + } + } + + /// `Equatable` + `Sendable` box around an underlying `Error` so the wrapped + /// cases keep `ConversationError`'s auto-synthesized conformances. Errors are + /// treated as immutable (hence `@unchecked Sendable`), and any two boxes compare + /// equal โ€” so equality stays driven by the case's description string. + public struct UnderlyingError: @unchecked Sendable, Equatable { + public let error: any Error + public init(_ error: any Error) { self.error = error } + public static func == (_: Self, _: Self) -> Bool { true } + } } diff --git a/Sources/ElevenLabs/Public/Conversation/ConversationState.swift b/Sources/ElevenLabs/Public/Conversation/ConversationState.swift index c5debaaa..e70f86bd 100644 --- a/Sources/ElevenLabs/Public/Conversation/ConversationState.swift +++ b/Sources/ElevenLabs/Public/Conversation/ConversationState.swift @@ -1,24 +1,69 @@ import Foundation +/// The lifecycle of a conversation, from idle through connecting to a terminal +/// state. Surfaced via ``ConversationClient/state``. +/// +/// The fine-grained connection/handshake progress is carried as the associated +/// ``StartupPhase`` of ``connecting``; callers that only care about the coarse +/// lifecycle can match `case .connecting` and ignore the phase. public enum ConversationState: Equatable, Sendable { + /// No active session. Also the resting state before the first `start`. case idle - case connecting - case active(CallInfo) + /// Connecting and running the startup handshake. The associated + /// ``StartupPhase`` reports how far along the sequence has progressed. + case connecting(phase: StartupPhase) + /// Startup is complete; the conversation is live. + case connected + /// The conversation was connected and then stopped. See ``EndReason``. case ended(reason: EndReason) - case error(ConversationError) + /// Startup failed before reaching ``connected``. The associated + /// ``ConversationStartupFailure`` carries the underlying error. (Mid-session + /// drops surface as ``ended`` with ``EndReason/remoteDisconnected``.) + case startupFailed(ConversationStartupFailure) - public var isActive: Bool { - if case .active = self { return true } - return false + /// `true` unless the conversation is connecting or connected, i.e. the + /// session is not occupying the transport and a new one may be started. + public var isInactive: Bool { + switch self { + case .connecting, .connected: return false + default: return true + } } - var isEnded: Bool { - if case .ended = self { return true } + /// `true` while connecting (in any ``StartupPhase``). + public var isConnecting: Bool { + if case .connecting = self { return true } return false } +} - var activeAgentId: String? { - if case let .active(info) = self { return info.agentId } - return nil - } +/// The phases a conversation moves through while connecting, before it is fully +/// live. Carried as the associated value of ``ConversationState/connecting(phase:)``. +/// +/// The phases are transport-neutral and map onto both the voice (WebRTC/LiveKit) +/// and text (WebSocket) connection sequences: +/// +/// - ``authorizing``: voice fetches a LiveKit token; text builds/validates the +/// WebSocket URL from `auth`. +/// - ``requestingMicPermission``: voice only โ€” blocking on the user's response +/// to the microphone permission prompt. Only reported on a cold grant: the +/// prompt is requested concurrently with the token fetch (so it overlaps +/// network latency), and an already-decided permission is never surfaced as a +/// separate phase. +/// - ``connecting``: voice connects the room and enables the mic; text opens the +/// WebSocket and completes the handshake. +/// - ``waitingForAgent(timeout:)``: voice only โ€” waiting for the agent to join +/// the room (the remote participant), bounded by the configured timeout. +/// - ``sendingInitData``: the `conversation_initiation_client_data` handshake +/// is being sent (both). +/// - ``waitingForInitData``: the handshake was sent; waiting for the server to +/// acknowledge with `conversation_initiation_metadata` (both). Startup is not +/// complete until this arrives. +public enum StartupPhase: Equatable, Sendable { + case authorizing + case requestingMicPermission + case connecting + case waitingForAgent(timeout: TimeInterval) + case sendingInitData + case waitingForInitData } diff --git a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupFailure.swift b/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupFailure.swift index 61df605b..fb9ad4a3 100644 --- a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupFailure.swift +++ b/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupFailure.swift @@ -1,8 +1,25 @@ import Foundation -public enum ConversationStartupFailure: Sendable, Equatable { +/// The reason a conversation failed to start, carried by +/// ``ConversationState/startupFailed(_:)`` and thrown out of `connect`. +public enum ConversationStartupFailure: Error, Sendable, Equatable { case token(ConversationError) case room(ConversationError) + case microphone(ConversationError) case agentTimeout case conversationInit(ConversationError) + + /// The underlying ``ConversationError`` for this failure, surfaced to + /// ``ConversationCallbacks/onError`` and rethrown from `connect`. + public var error: ConversationError { + switch self { + case let .token(error), + let .room(error), + let .microphone(error), + let .conversationInit(error): + return error + case .agentTimeout: + return .agentTimeout + } + } } diff --git a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupState.swift b/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupState.swift deleted file mode 100644 index 5b168126..00000000 --- a/Sources/ElevenLabs/Public/Conversation/Startup/ConversationStartupState.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -public enum ConversationStartupState: Sendable, Equatable { - case idle - case resolvingToken - case connectingRoom - case waitingForAgent(timeout: TimeInterval) - case agentReady(ConversationAgentReadyReport) - case sendingConversationInit(attempt: Int) - case active(CallInfo, ConversationStartupMetrics) - case failed(ConversationStartupFailure, ConversationStartupMetrics) -} diff --git a/Sources/ElevenLabs/Public/Events/ConversationCallbacks.swift b/Sources/ElevenLabs/Public/Events/ConversationCallbacks.swift new file mode 100644 index 00000000..2d05a3da --- /dev/null +++ b/Sources/ElevenLabs/Public/Events/ConversationCallbacks.swift @@ -0,0 +1,99 @@ +import Foundation + +public struct ConversationCallbacks: Sendable { + /// Called when the agent is ready and the conversation can begin + public var onAgentReady: (@Sendable () -> Void)? + + /// Called when the agent disconnects or the conversation ends + public var onDisconnect: (@Sendable (DisconnectionReason) -> Void)? + + /// Called on a startup failure or a mid-session server error. + public var onError: (@Sendable (ConversationError) -> Void)? + + /// Called for each agent response with the associated event identifier. + public var onAgentResponse: (@Sendable (_ text: String, _ eventId: Int) -> Void)? + + /// Called for each streamed agent response part (`agent_chat_response_part`). + /// `type` frames the stream: `.start` and `.stop` are boundary markers whose + /// `text` is empty, while `.delta` carries an incremental text chunk. The + /// accumulated message is also available via ``ConversationClient/messages`` + /// (marked ``Message/isPartial`` until the finalized ``onAgentResponse`` arrives). + public var onAgentResponsePart: (@Sendable (_ text: String, _ type: AgentChatResponsePartType, _ eventId: Int) -> Void)? + + /// Called when an agent response correction is received. + public var onAgentResponseCorrection: (@Sendable (_ original: String, _ corrected: String, _ eventId: Int) -> Void)? + + /// Called when agent response metadata is received. + public var onAgentResponseMetadata: (@Sendable (_ metadataData: Data, _ eventId: Int) -> Void)? + + /// Called for each user transcript event. + public var onUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? + + /// Called for each tentative (in-progress) user transcript. Use this to show + /// live captions while the user is still speaking; a final + /// ``onUserTranscript`` with the same `eventId` follows once finalized. + public var onTentativeUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? + + /// Called when the agent emits a tool response event. + public var onAgentToolResponse: (@Sendable (AgentToolResponseEvent) -> Void)? + + /// Called when the agent requests a tool execution. + public var onAgentToolRequest: (@Sendable (AgentToolRequestEvent) -> Void)? + + /// Called when the agent detects an interruption. + public var onInterruption: (@Sendable (_ eventId: Int) -> Void)? + + /// Called whenever a VAD score is emitted. + public var onVadScore: (@Sendable (_ score: Double) -> Void)? + + /// Called when audio alignment metadata is emitted. + public var onAudioAlignment: (@Sendable (AudioAlignment) -> Void)? + + /// Called when the server reports a round-trip latency sample, in milliseconds. + public var onPing: (@Sendable (_ pingMs: Int) -> Void)? + + /// Called when the agent requests a client tool call. + public var onClientToolCall: (@Sendable (ClientToolCallEvent) -> Void)? + + @available(*, deprecated, renamed: "onClientToolCall") + public var onUnhandledClientToolCall: (@Sendable (ClientToolCallEvent) -> Void)? { + get { onClientToolCall } + set { onClientToolCall = newValue } + } + + public init( + onAgentReady: (@Sendable () -> Void)? = nil, + onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil, + onError: (@Sendable (ConversationError) -> Void)? = nil, + onAgentResponse: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil, + onAgentResponsePart: (@Sendable (_ text: String, _ type: AgentChatResponsePartType, _ eventId: Int) -> Void)? = nil, + onAgentResponseCorrection: (@Sendable (_ original: String, _ corrected: String, _ eventId: Int) -> Void)? = nil, + onAgentResponseMetadata: (@Sendable (_ metadataData: Data, _ eventId: Int) -> Void)? = nil, + onUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil, + onTentativeUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil, + onAgentToolResponse: (@Sendable (AgentToolResponseEvent) -> Void)? = nil, + onAgentToolRequest: (@Sendable (AgentToolRequestEvent) -> Void)? = nil, + onInterruption: (@Sendable (_ eventId: Int) -> Void)? = nil, + onVadScore: (@Sendable (_ score: Double) -> Void)? = nil, + onAudioAlignment: (@Sendable (AudioAlignment) -> Void)? = nil, + onPing: (@Sendable (_ pingMs: Int) -> Void)? = nil, + onClientToolCall: (@Sendable (ClientToolCallEvent) -> Void)? = nil + ) { + self.onAgentReady = onAgentReady + self.onDisconnect = onDisconnect + self.onError = onError + self.onAgentResponse = onAgentResponse + self.onAgentResponsePart = onAgentResponsePart + self.onAgentResponseCorrection = onAgentResponseCorrection + self.onAgentResponseMetadata = onAgentResponseMetadata + self.onUserTranscript = onUserTranscript + self.onTentativeUserTranscript = onTentativeUserTranscript + self.onAgentToolResponse = onAgentToolResponse + self.onAgentToolRequest = onAgentToolRequest + self.onInterruption = onInterruption + self.onVadScore = onVadScore + self.onAudioAlignment = onAudioAlignment + self.onPing = onPing + self.onClientToolCall = onClientToolCall + } +} diff --git a/Sources/ElevenLabs/Public/LiveKit/LiveKitNetworkConfiguration.swift b/Sources/ElevenLabs/Public/LiveKit/LiveKitNetworkConfiguration.swift deleted file mode 100644 index 049af2b4..00000000 --- a/Sources/ElevenLabs/Public/LiveKit/LiveKitNetworkConfiguration.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import LiveKit - -/// Controls how the SDK establishes LiveKit peer connections. -/// -/// The default configuration uses automatic ICE candidate gathering (``Strategy/automatic``) -/// which allows direct peer-to-peer connections when possible, falling back to TURN relays as needed. -/// You can force TURN-only connectivity (``Strategy/relayOnly``) to avoid the iOS local network -/// permission prompt, or provide a fully custom transport policy and ICE server list if needed. -public struct LiveKitNetworkConfiguration: Sendable { - /// Describes how ICE transport candidates should be gathered. - public enum Strategy: Sendable, Equatable { - /// Use LiveKit/WebRTC defaults (gather all candidate types). - case automatic - /// Force TURN relay candidates only. - case relayOnly - /// Provide a specific ``IceTransportPolicy``. - case custom(IceTransportPolicy) - } - - /// The strategy to use for ICE gathering. Defaults to ``Strategy/automatic``. - public var strategy: Strategy - - /// Optional custom ICE servers to use instead of those supplied by the ElevenLabs backend. - public var customIceServers: [IceServer] - - public init(strategy: Strategy = .automatic, customIceServers: [IceServer] = []) { - self.strategy = strategy - self.customIceServers = customIceServers - } - - /// Default configuration (automatic ICE candidate gathering, no custom ICE servers). - public static let `default` = LiveKitNetworkConfiguration() -} - -extension LiveKitNetworkConfiguration { - var resolvedIceTransportPolicy: IceTransportPolicy { - switch strategy { - case .automatic: - .all - case .relayOnly: - .relay - case let .custom(policy): - policy - } - } - - var requiresCustomConnectOptions: Bool { - strategy != .automatic || !customIceServers.isEmpty - } - - @MainActor - func makeConnectOptions() -> ConnectOptions? { - guard requiresCustomConnectOptions else { - return nil - } - - let policy = resolvedIceTransportPolicy - #if os(iOS) - if policy == .relay { - LocalNetworkPermissionMonitor.shared.recordRelayRequested() - } - #endif - - return ConnectOptions( - iceServers: customIceServers, - iceTransportPolicy: policy - ) - } -} diff --git a/Tests/ElevenLabsTests/Unit/ConversationConfigTests.swift b/Tests/ElevenLabsTests/Unit/ConversationConfigTests.swift index 206f8399..6dedd707 100644 --- a/Tests/ElevenLabsTests/Unit/ConversationConfigTests.swift +++ b/Tests/ElevenLabsTests/Unit/ConversationConfigTests.swift @@ -7,7 +7,7 @@ final class ConversationConfigTests: XCTestCase { XCTAssertNil(config.agentOverrides) XCTAssertNil(config.ttsOverrides) - XCTAssertNil(config.conversationOverrides) + XCTAssertFalse(config.textOnly) } func testConfigurationWithOverrides() { @@ -23,13 +23,11 @@ final class ConversationConfigTests: XCTestCase { voiceId: "voice123" ) - config.conversationOverrides = ConversationOverrides( - textOnly: true - ) + config.textOnly = true XCTAssertNotNil(config.agentOverrides) XCTAssertNotNil(config.ttsOverrides) - XCTAssertNotNil(config.conversationOverrides) + XCTAssertTrue(config.textOnly) } func testAgentOverrides() { @@ -52,12 +50,10 @@ final class ConversationConfigTests: XCTestCase { XCTAssertEqual(overrides.voiceId, "voice123") } - func testConversationOverrides() { - let overrides = ConversationOverrides( - textOnly: true - ) + func testTextOnlyConfiguration() { + let config = ConversationConfig(textOnly: true) - XCTAssertEqual(overrides.textOnly, true) + XCTAssertTrue(config.textOnly) } func testLanguageEnum() { From f126816f9e5ec2dc654b3315b32708e10cbe18a4 Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:29:28 +0100 Subject: [PATCH 4/8] feat(auth): move room-token HTTP into TokenService, drop hardcoded endpoints TokenService.fetchConnectionDetails(configuration:) -> ConnectionDetails (serverUrl/roomName/participantToken bundle) becomes fetchRoomToken(auth:apiBase:environment:) -> String -- WebRTCConnectionManager now owns constructing the room connection from that token plus ConversationConfig.endpoints instead of TokenService handing back a ready-made LiveKit connection bundle. The .customTokenProvider auth case has no replacement (matches ConversationAuth). ConnectionConstants' three hardcoded URLs are deleted now that endpoints are configurable per-session via ElevenLabsEndpoints. --- .../Authorization/ConnectionConstants.swift | 9 -- .../Authorization/TokenServicing.swift | 8 +- .../Public/Authorization/TokenService.swift | 127 +++++++----------- 3 files changed, 48 insertions(+), 96 deletions(-) delete mode 100644 Sources/ElevenLabs/Internal/Authorization/ConnectionConstants.swift diff --git a/Sources/ElevenLabs/Internal/Authorization/ConnectionConstants.swift b/Sources/ElevenLabs/Internal/Authorization/ConnectionConstants.swift deleted file mode 100644 index f15c5adb..00000000 --- a/Sources/ElevenLabs/Internal/Authorization/ConnectionConstants.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -enum ConnectionConstants { - /// LiveKit signaling endpoint used for voice conversations. - static let voiceConversationUrl = "wss://livekit.rtc.elevenlabs.io" - /// WebSocket endpoint used for text-only conversations. - static let textConversationUrl = "wss://api.elevenlabs.io/v1/convai/conversation" - static let tokenUrl = "https://api.elevenlabs.io/v1/convai/conversation/token" -} diff --git a/Sources/ElevenLabs/Internal/Authorization/TokenServicing.swift b/Sources/ElevenLabs/Internal/Authorization/TokenServicing.swift index 57152304..58aa7249 100644 --- a/Sources/ElevenLabs/Internal/Authorization/TokenServicing.swift +++ b/Sources/ElevenLabs/Internal/Authorization/TokenServicing.swift @@ -1,10 +1,6 @@ import Foundation protocol TokenServicing: Sendable { - /// Fetch connection details for ElevenLabs conversation - /// - Parameter configuration: The configuration to use for fetching connection details - /// - Returns: The connection details for the ElevenLabs conversation - func fetchConnectionDetails(configuration: ElevenLabsConfiguration) async throws -> TokenService.ConnectionDetails + /// Fetch a LiveKit room token for an ElevenLabs conversation. + func fetchRoomToken(auth: ConversationAuth, apiBase: URL, environment: String?) async throws -> String } - -extension TokenService: TokenServicing {} diff --git a/Sources/ElevenLabs/Public/Authorization/TokenService.swift b/Sources/ElevenLabs/Public/Authorization/TokenService.swift index 3baf073e..7b76951e 100644 --- a/Sources/ElevenLabs/Public/Authorization/TokenService.swift +++ b/Sources/ElevenLabs/Public/Authorization/TokenService.swift @@ -16,101 +16,72 @@ import Foundation /// Service for managing ElevenLabs authentication /// This is designed to be stateless and SDK-friendly -public struct TokenService: Sendable { - public struct ConnectionDetails: Codable, Sendable { - public let serverUrl: String - public let roomName: String - public let participantName: String - public let participantToken: String - } - - /// Optional configuration for advanced use cases - public struct Configuration: Sendable { - /// Custom API endpoint (for testing or enterprise deployments) - public let apiEndpoint: String? - /// Custom WebSocket URL (for testing or enterprise deployments) - public let websocketURL: String? - - public init(apiEndpoint: String? = nil, websocketURL: String? = nil) { - self.apiEndpoint = apiEndpoint - self.websocketURL = websocketURL - } +public struct TokenService: TokenServicing, Sendable { + /// Path of the conversation-token endpoint, relative to `apiBase`. + private static let conversationTokenPath = "/v1/convai/conversation/token" - public static let `default` = Configuration() - } - - private let configuration: Configuration private let urlSession: URLSession // Development-only API key for testing private agents // This should only be set in debug builds for local testing #if DEBUG + private static let debugLogger: any Logging = SDKLogger() public let debugApiKey: String? public init( - configuration: Configuration = .default, urlSession: URLSession = .shared, debugApiKey: String? = nil ) { - self.configuration = configuration self.urlSession = urlSession self.debugApiKey = debugApiKey } #else public init( - configuration: Configuration = .default, urlSession: URLSession = .shared ) { - self.configuration = configuration self.urlSession = urlSession } #endif - /// Fetch connection details for ElevenLabs conversation. - /// - /// Translates internal `TokenError`s into public `ConversationError`s so - /// callers only ever deal with one error type. - public func fetchConnectionDetails(configuration: ElevenLabsConfiguration) async throws -> ConnectionDetails { - do { - let token: String = switch configuration.authSource { - case let .publicAgentId(agentId): - try await fetchTokenFromAPI(agentId: agentId, environment: configuration.environment) - case let .conversationToken(conversationToken): - conversationToken - case .signedWebSocketURL: - throw ConversationError.authenticationFailed( - "Signed WebSocket URLs are only supported for text-only conversations." - ) - case let .customTokenProvider(provider): - try await provider() - } - - let websocketURL = self.configuration.websocketURL ?? ConnectionConstants.voiceConversationUrl - - // ElevenLabs tokens contain room name and participant identity in the JWT - // LiveKit will extract these automatically, so we provide empty values - return ConnectionDetails( - serverUrl: websocketURL, - roomName: "", // LiveKit extracts from JWT - participantName: "", // LiveKit extracts from JWT - participantToken: token + /// Fetch a LiveKit room token for an ElevenLabs conversation. + public func fetchRoomToken( + auth: ConversationAuth, + apiBase: URL, + environment: String? = nil + ) async throws -> String { + switch auth.authSource { + case let .publicAgentId(agentId): + return try await fetchRoomTokenFromElevenlabsAPI( + agentId: agentId, + apiBase: apiBase, + environment: environment ) - } catch let error as ConversationError { - throw error - } catch let error as TokenError { - throw ConversationError.authenticationFailed(error.localizedDescription) - } catch { - throw ConversationError.connectionFailed(error) + case let .conversationToken(conversationToken): + return conversationToken + case .signedWebSocketURL: + throw ConversationError.authenticationFailed("Signed WebSocket URLs are only supported for text-only conversations.") } } - private func fetchTokenFromAPI(agentId: String, environment: String? = nil) async throws -> String { - // Build URL with agent ID as query parameter - let apiUrl = configuration.apiEndpoint ?? ConnectionConstants.tokenUrl - - guard var components = URLComponents(string: apiUrl) else { + /// Mint a room token for a public agent via + /// `GET /v1/convai/conversation/token?agent_id=โ€ฆ`. Throws ``TokenError`` + /// (mapped onto the user-facing `ConversationError` by the caller). + /// + /// `debugApiKey` forwards an `xi-api-key` for local private-agent testing; + /// it is always `nil` in release builds โ€” never ship a key. + private func fetchRoomTokenFromElevenlabsAPI( + agentId: String, + apiBase: URL, + environment: String? + ) async throws -> String { + // Join the token path onto apiBase, tolerating a trailing slash. + guard var components = URLComponents(url: apiBase, resolvingAgainstBaseURL: false) else { throw TokenError.invalidURL } + let base = components.percentEncodedPath + let trimmedBase = base.hasSuffix("/") ? String(base.dropLast()) : base + components.percentEncodedPath = trimmedBase + Self.conversationTokenPath + var queryItems = [ URLQueryItem(name: "agent_id", value: agentId), URLQueryItem(name: "source", value: "swift_sdk"), @@ -127,39 +98,33 @@ public struct TokenService: Sendable { var request = URLRequest(url: url) request.httpMethod = "GET" - - // DEVELOPMENT ONLY: Check for API key - // This is ONLY for local development/testing. NEVER ship an app with an API key! #if DEBUG - if let apiKey = debugApiKey { - let logger = SDKLogger(logLevel: .warning) - logger.warning("Using API key in client - DEVELOPMENT ONLY!") - logger.warning("For production, implement a backend service to generate tokens") - request.setValue(apiKey, forHTTPHeaderField: "xi-api-key") + if let debugApiKey { + Self.debugLogger.warning("Using API key in client - DEVELOPMENT ONLY!") + Self.debugLogger.warning("For production, implement a backend service to generate tokens") + request.setValue(debugApiKey, forHTTPHeaderField: "xi-api-key") } #endif let (data, response) = try await urlSession.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { + guard let http = response as? HTTPURLResponse else { throw TokenError.invalidResponse } - - guard httpResponse.statusCode == 200 else { - if httpResponse.statusCode == 401 { + guard http.statusCode == 200 else { + if http.statusCode == 401 { throw TokenError.authenticationFailed } - throw TokenError.httpError(statusCode: httpResponse.statusCode) + throw TokenError.httpError(statusCode: http.statusCode) } - // Parse response - ElevenLabs returns {"token": "..."} + // ElevenLabs returns {"token": "..."}. guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let token = json["token"] as? String, !token.isEmpty else { throw TokenError.invalidTokenResponse } - return token } } From 1f6ad3f579182e968fc7c00f7b94c9cc042e0e09 Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:29:49 +0100 Subject: [PATCH 5/8] feat(networking): rewrite connection managers around ConversationConfig/Auth - ConnectionManaging: connect(auth:config:) throws unifies what was a differently-shaped connect() per concrete manager; onStartupPhaseChange replaces errorHandler (typed ConversationStartupFailure throws instead of a generic error callback). - WebRTCConnectionManager: mic-permission request now runs concurrently with the token fetch (was sequential); readiness gate races LiveKitReadinessDelegate.awaitRemoteParticipant() (first remote participant joins) instead of waiting for the agent's audio track to subscribe -- this specific change already shipped separately as #197, landing here only because the v4 import was authored before that merged. Startup timing is now logged, not returned to the caller. Also drops the LocalNetworkPermissionMonitor-based "check Local Network permission" diagnostic with nothing replacing it. - Dependencies: init(logLevel:) replaces a parameterless init; TokenService is now constructed with no endpoint config baked in. Adds an opt-in LogLevel.debugWithRTC tier that forwards LiveKit/WebRTC internal logs. - SDKLogger: default level changes from .info to .warning; logLevel param renamed to levelOverride. - LiveKitReadinessDelegate (in WebRTCConnectionManager.swift) changed from private back to internal so LiveKitReadinessDelegateTests (added by #197) can still construct it via @testable import; its call site there is updated for SDKLogger's renamed parameter. --- .../Conversation/ConnectionManaging.swift | 17 +- .../ElevenLabs/Internal/DI/Dependencies.swift | 23 +- .../LocalNetworkPermissionMonitor.swift | 51 -- .../Networking/WebRTCConnectionManager.swift | 459 ++++++++++-------- .../WebSocketConnectionManager.swift | 60 ++- .../Internal/Utilities/SDKLogger.swift | 11 +- .../Mocks/MockWebRTCConnectionManager.swift | 117 +++-- .../MockWebSocketConnectionManager.swift | 53 +- .../Mocks/TestDependencyProvider.swift | 2 +- .../Unit/LiveKitRoomEventDelegateTests.swift | 2 +- 10 files changed, 419 insertions(+), 376 deletions(-) delete mode 100644 Sources/ElevenLabs/Internal/Networking/LocalNetworkPermissionMonitor.swift diff --git a/Sources/ElevenLabs/Internal/Conversation/ConnectionManaging.swift b/Sources/ElevenLabs/Internal/Conversation/ConnectionManaging.swift index b8e36b29..16c897da 100644 --- a/Sources/ElevenLabs/Internal/Conversation/ConnectionManaging.swift +++ b/Sources/ElevenLabs/Internal/Conversation/ConnectionManaging.swift @@ -5,32 +5,27 @@ enum ConnectionManagerError: Error { case notConnected } +@MainActor protocol ConnectionManaging: AnyObject { var onEventReceived: (@Sendable (IncomingEvent) -> Void)? { get set } var onDisconnected: (() async -> Void)? { get set } - var errorHandler: ((Swift.Error?) -> Void)? { get set } + var onStartupPhaseChange: ((StartupPhase) -> Void)? { get set } + func connect(auth: ConversationAuth, config: ConversationConfig) async throws func disconnect() async func send(data: Data) async throws } -protocol WebSocketConnectionManaging: ConnectionManaging { - func connect(auth: ElevenLabsConfiguration, options: ConversationOptions) async throws -> StartupResult -} +@MainActor +protocol WebSocketConnectionManaging: ConnectionManaging {} +@MainActor protocol WebRTCConnectionManaging: ConnectionManaging { var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? { get set } var inputTrack: LocalAudioTrack? { get } var agentAudioTrack: RemoteAudioTrack? { get } var isMicrophoneMuted: Bool { get } - @MainActor - func connect( - auth: ElevenLabsConfiguration, - options: ConversationOptions, - onStartupStateChange: @escaping (ConversationStartupState) -> Void - ) async throws -> StartupResult - func setMicrophoneMuted(_ muted: Bool) async throws } diff --git a/Sources/ElevenLabs/Internal/DI/Dependencies.swift b/Sources/ElevenLabs/Internal/DI/Dependencies.swift index 8c2e8cb1..c6b71b97 100644 --- a/Sources/ElevenLabs/Internal/DI/Dependencies.swift +++ b/Sources/ElevenLabs/Internal/DI/Dependencies.swift @@ -1,4 +1,5 @@ import Foundation +import LiveKit @MainActor protocol ConversationDependencyProvider: AnyObject { @@ -7,21 +8,23 @@ protocol ConversationDependencyProvider: AnyObject { var webSocketConnectionManager: any WebSocketConnectionManaging { get } } -/// A minimalistic dependency container for internal SDK use. @MainActor final class Dependencies: ConversationDependencyProvider { - let logger: any Logging let webRTCConnectionManager: any WebRTCConnectionManaging + let webSocketConnectionManager: any WebSocketConnectionManaging - init() { - let globalConfig = ElevenLabs.Global.shared.configuration - let tokenService = TokenService(configuration: TokenService.Configuration( - apiEndpoint: globalConfig.apiEndpoint?.absoluteString, - websocketURL: globalConfig.websocketUrl - )) - let logger = SDKLogger(logLevel: globalConfig.logLevel) - self.logger = logger + let logger: any Logging + + init(logLevel: LogLevel = .warning) { + let tokenService: any TokenServicing = TokenService() + logger = SDKLogger(levelOverride: logLevel) + // Only the dedicated `.debugWithRTC` tier forwards LiveKit + underlying + // WebRTC logs (ICE server list, candidate gathering, TURN allocation). + // Kept off `.debug`/`.trace` since the RTC firehose is very noisy. + if logLevel.forwardsRTCLogs { + LiveKitSDK.setLogger(OSLogger(minLevel: .debug, rtc: true)) + } webRTCConnectionManager = WebRTCConnectionManager(logger: logger, tokenService: tokenService) webSocketConnectionManager = WebSocketConnectionManager(logger: logger) } diff --git a/Sources/ElevenLabs/Internal/Networking/LocalNetworkPermissionMonitor.swift b/Sources/ElevenLabs/Internal/Networking/LocalNetworkPermissionMonitor.swift deleted file mode 100644 index a92f1c2e..00000000 --- a/Sources/ElevenLabs/Internal/Networking/LocalNetworkPermissionMonitor.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation -import Network - -#if os(iOS) -/// Tracks iOS local network permission status so the SDK can surface actionable errors. -/// -/// iOS does not expose a direct API to query permission state. Instead we: -/// 1. Subscribe to `NWPathMonitor` updates to watch for `requiresLocalNetworkAuthorization`. -/// 2. Attempt a harmless UDP bind once to trigger the system prompt when appropriate. -/// -/// This monitor keeps lightweight state so that connection errors that stem from denied -/// local-network permission can be annotated for SDK consumers. -@MainActor -final class LocalNetworkPermissionMonitor { - static let shared = LocalNetworkPermissionMonitor() - - private let pathMonitor: NWPathMonitor - private var lastPath: NWPath? - private var relayRequested: Bool = false - - private init() { - pathMonitor = NWPathMonitor(requiredInterfaceType: .wifi) - pathMonitor.pathUpdateHandler = { [weak self] path in - Task { @MainActor in - self?.lastPath = path - } - } - pathMonitor.start(queue: .main) - } - - func recordRelayRequested() { - relayRequested = true - } - - func shouldSuggestLocalNetworkPermission() -> Bool { - guard relayRequested else { return false } - guard let path = lastPath else { return false } - return path.status == .satisfied && path.isConstrained - } -} -#else -@MainActor -final class LocalNetworkPermissionMonitor { - static let shared = LocalNetworkPermissionMonitor() - private init() {} - func recordRelayRequested() {} - func shouldSuggestLocalNetworkPermission() -> Bool { - false - } -} -#endif diff --git a/Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift b/Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift index c7146bf5..246a74bd 100644 --- a/Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift +++ b/Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift @@ -1,16 +1,7 @@ -import AVFoundation import Foundation +import AVFoundation import LiveKit -enum AgentReadyWaitResult: Equatable { - case success(elapsed: TimeInterval) - case timedOut(elapsed: TimeInterval) -} - -enum WebRTCConnectionManagerError: Error { - case roomUnavailable -} - /// Faรงade around `LiveKit.Room`. /// /// Owns the room lifecycle, microphone control, data publish/receive, and @@ -19,12 +10,8 @@ enum WebRTCConnectionManagerError: Error { /// /// LiveKit observation is split across two `RoomDelegate` instances: /// - `LiveKitRoomEventDelegate` โ€” data, speaking, remote disconnect -/// - `LiveKitReadinessDelegate` โ€” signals when the agent's audio track subscribes -/// -/// Note: `Room`, `LocalAudioTrack`, and `RemoteAudioTrack` are intentionally -/// exposed on the public SDK surface (e.g. `Conversation.inputTrack`), so this -/// type does not fully hide LiveKit from callers. It does centralize the -/// dependency in one place. +/// - `LiveKitReadinessDelegate` โ€” signals when the first remote participant joins +@MainActor final class WebRTCConnectionManager: WebRTCConnectionManaging { /// Fired when the remote agent leaves, the room disconnects, or all remote participants are gone. var onDisconnected: (() async -> Void)? @@ -35,7 +22,8 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging { /// Fired when a remote participant starts or stops speaking. var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? - var errorHandler: ((Swift.Error?) -> Void)? + /// Reports startup-phase transitions during `connect`. + var onStartupPhaseChange: ((StartupPhase) -> Void)? // MARK: โ€“ Public state accessors @@ -46,7 +34,26 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging { } var agentAudioTrack: RemoteAudioTrack? { - room?.remoteParticipants.values.first?.firstAudioPublication?.track as? RemoteAudioTrack + agentParticipant?.firstAudioPublication?.track as? RemoteAudioTrack + } + + /// The agent's remote participant, identified by the `agent` identity prefix + /// the orchestrator assigns. Falls back to the sole remote participant since + /// a conversation only ever has the agent on the far side. + private var agentParticipant: RemoteParticipant? { + let remotes = room?.remoteParticipants.values + return remotes?.first(where: Self.isAgentParticipant) ?? remotes?.first + } + + /// Identity-prefix the orchestrator uses to name the agent participant. + private nonisolated static let agentIdentityPrefix = "agent" + + /// Whether `participant` is the agent, by its identity prefix. Shared by the + /// track accessor and the disconnect delegate so both agree on what "agent" + /// means. + nonisolated static func isAgentParticipant(_ participant: Participant) -> Bool { + guard let identity = participant.identity else { return false } + return String(describing: identity).hasPrefix(agentIdentityPrefix) } var isMicrophoneMuted: Bool { @@ -62,203 +69,217 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging { private static let reliableDataPublishOptions = DataPublishOptions(reliable: true) private let logger: any Logging - private let tokenService: any TokenServicing + private let tokenService: TokenServicing - init(logger: any Logging, tokenService: any TokenServicing) { + init(logger: any Logging, tokenService: TokenServicing) { self.logger = logger self.tokenService = tokenService } // MARK: โ€“ Public API - /// Full WebRTC startup sequence: resolve token โ†’ request mic permission โ†’ - /// connect room โ†’ wait for agent โ†’ send conversation_init (sent once). - @MainActor + /// Establish the LiveKit connection and send the conversation-init handshake, + /// driving `onStartupPhaseChange` through the startup sequence. func connect( - auth: ElevenLabsConfiguration, - options: ConversationOptions, - onStartupStateChange: @escaping (ConversationStartupState) -> Void - ) async throws -> StartupResult { - let startTime = Date() - var metrics = ConversationStartupMetrics() - logger.info("Starting conversation startup sequence", context: ["agentId": auth.agentId]) - - // 1. Resolve token / connection details. - onStartupStateChange(.resolvingToken) - let connectionDetails = try await runPhase( - timing: \.tokenFetch, metrics: &metrics, startTime: startTime, failure: StartupFailure.token - ) { - try await tokenService.fetchConnectionDetails(configuration: auth) - } - - // 2. Request microphone permission (denial doesn't block startup). - let permissionGranted = await requestMicrophonePermission() - - // 3. Connect the LiveKit room. - onStartupStateChange(.connectingRoom) - let throwOnMicFailure = options.microphoneFailureHandling == .throwError - try await runPhase( - timing: \.roomConnect, metrics: &metrics, startTime: startTime, failure: StartupFailure.room - ) { - try await connectToRoom( - details: connectionDetails, - enableMic: permissionGranted, - throwOnMicrophoneFailure: throwOnMicFailure, - networkConfiguration: options.networkConfiguration - ) - } - - // 4. Wait for the agent to be ready (fails outright if it doesn't join in time). - let agentTimeout = options.startupConfiguration.agentReadyTimeout - onStartupStateChange(.waitingForAgent(timeout: agentTimeout)) - guard case let .success(elapsed) = await waitForAgentReady(timeout: agentTimeout) else { - metrics.total = Date().timeIntervalSince(startTime) - logger.warning("Agent not ready within \(String(format: "%.3f", agentTimeout))s") - throw StartupFailure.agentTimeout(metrics) - } - metrics.agentReady = elapsed - onStartupStateChange(.agentReady(ConversationAgentReadyReport(elapsed: elapsed))) - - // 5. Send conversation_initiation_client_data (sent once). - onStartupStateChange(.sendingConversationInit(attempt: 1)) - try await runPhase( - timing: \.conversationInit, metrics: &metrics, startTime: startTime, - failure: StartupFailure.conversationInit - ) { - try await send(event: .conversationInit(ConversationInitEvent(config: options.toConversationConfig()))) - } - metrics.conversationInitAttempts = 1 - - metrics.total = Date().timeIntervalSince(startTime) - return StartupResult(agentId: auth.agentId, metrics: metrics) - } - - /// Race the delegate's "first remote participant joined" signal against `timeout`. - /// A `.timedOut` result makes `connect` fail with `StartupFailure.agentTimeout`. - private func waitForAgentReady(timeout: TimeInterval) async -> AgentReadyWaitResult { - guard let delegate = readinessDelegate else { - return .timedOut(elapsed: 0) - } - let start = Date() - return await withTaskGroup(of: AgentReadyWaitResult.self) { group in - group.addTask { - do { - try await delegate.awaitRemoteParticipant() - return .success(elapsed: Date().timeIntervalSince(start)) - } catch { - return .timedOut(elapsed: Date().timeIntervalSince(start)) - } - } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - return .timedOut(elapsed: Date().timeIntervalSince(start)) - } - let first = await group.next()! - group.cancelAll() - return first - } - } + auth: ConversationAuth, + config: ConversationConfig + ) async throws { + let endpoints = config.endpoints + // Connect-phase timings, emitted via `logger.debug` at the end of connect. + let tStart = Date() - func send(data: Data) async throws { - guard let room else { - throw ConnectionManagerError.notConnected - } - do { - try await room.localParticipant.publish(data: data, options: Self.reliableDataPublishOptions) - } catch { - errorHandler?(error) - throw error - } - } + // Authorizing: fetch the LiveKit token (mic permission resolves + // concurrently just below). + onStartupPhaseChange?(.authorizing) - func setMicrophoneMuted(_ muted: Bool) async throws { - guard let room else { - throw WebRTCConnectionManagerError.roomUnavailable - } - do { - try await room.localParticipant.setMicrophone(enabled: !muted) - } catch { - errorHandler?(error) - throw error - } - } + // Create the room and register delegates before connecting, so the + // readiness delegate is in place to observe the agent joining. + readinessDelegate?.release() - /// Establish the LiveKit room connection (the low-level room/mic primitive - /// used by `connect`). - /// - /// - Parameters: - /// - details: Token-service credentials (URL + participant token). - /// - enableMic: Whether to enable the local microphone immediately. - /// - throwOnMicrophoneFailure: If true, throws error when microphone setup fails. - /// If false, logs warning and continues. - private func connectToRoom( - details: TokenService.ConnectionDetails, - enableMic: Bool, - throwOnMicrophoneFailure: Bool, - networkConfiguration: LiveKitNetworkConfiguration - ) async throws { - await readinessDelegate?.release() - - let readinessDelegate = await LiveKitReadinessDelegate(logger: logger) + let readinessDelegate = LiveKitReadinessDelegate(logger: logger) self.readinessDelegate = readinessDelegate let logger = logger let eventDelegate = LiveKitRoomEventDelegate( - onData: { [weak self] data in self?.handleIncomingData(data, logger: logger) }, - onRemoteSpeaking: { [weak self] isSpeaking in self?.onRemoteSpeakingChanged?(isSpeaking) }, - onRemoteDisconnect: { [weak self] in await self?.onDisconnected?() } + onData: { [weak self] data in + Task { @MainActor in self?.handleIncomingData(data, logger: logger) } + }, + onRemoteSpeaking: { [weak self] isSpeaking in + Task { @MainActor in self?.onRemoteSpeakingChanged?(isSpeaking) } + }, + onRemoteDisconnect: { [weak self] in await self?.notifyDisconnected() } ) self.eventDelegate = eventDelegate - let room = Room(roomOptions: RoomOptions(singlePeerConnection: true)) + let room = Room( + roomOptions: RoomOptions(singlePeerConnection: true) + ) self.room = room room.delegates.add(delegate: eventDelegate) room.delegates.add(delegate: readinessDelegate) - let connectOptions = await networkConfiguration.makeConnectOptions() + // Resolve mic permission concurrently with the token fetch: on a cold + // grant the prompt overlaps the token round-trip instead of running + // serially after it. + let voiceURL = endpoints.voiceWebSocket.absoluteString + async let micPermissionGranted = requestMicrophonePermission() + + let roomToken: String + let tTokenStart = Date() + do { + roomToken = try await tokenService.fetchRoomToken( + auth: auth, + apiBase: endpoints.apiBase, + environment: config.environment + ) + logger.debug("Token fetch successful") + } catch is CancellationError { + throw CancellationError() + } catch { + // Throws during `connect` are surfaced as `ConversationStartupFailure` + // so `Conversation.handleStartupFailure` can disconnect, reset to + // `.idle`, and report once via `onError`. A bare throw would escape + // that and leave the session wedged in `.connecting`. + let conversationError: ConversationError + switch error { + case let error as ConversationError: + conversationError = error + case let error as TokenError: + conversationError = switch error { + case .authenticationFailed: + .authenticationFailed(error.localizedDescription) + case let .httpError(statusCode): + .authenticationFailed("HTTP error: \(statusCode)") + case .invalidURL, .invalidResponse, .invalidTokenResponse: + .authenticationFailed(error.localizedDescription) + } + default: + conversationError = .connectionFailed(error) + } + throw ConversationStartupFailure.token(conversationError) + } + + let tToken = Date() + + // Network work is done; report the user-input wait as its own phase + // (distinct from the network-bound `.authorizing`) before blocking on the + // permission result. + onStartupPhaseChange?(.requestingMicPermission) + let enableMic = await micPermissionGranted + let tReady = Date() + + // No mic permission for a voice conversation. iOS only prompts once, so a + // denial is terminal until re-enabled in Settings. Fail fast before + // connecting a room we can't use. + if !enableMic, config.microphoneFailureHandling == .throwError { + throw ConversationStartupFailure.microphone(.microphonePermissionDenied) + } + + let connectOptions = Self.makeConnectOptions(for: config) + + // Connecting: establish the LiveKit room and (below) enable the mic. + onStartupPhaseChange?(.connecting) - let connectStart = Date() do { try await room.connect( - url: details.serverUrl, - token: details.participantToken, + url: voiceURL, + token: roomToken, connectOptions: connectOptions ) - logger.info("LiveKit room.connect completed", context: ["duration": "\(Date().timeIntervalSince(connectStart))"]) + logger.info("LiveKit room.connect completed") + } catch is CancellationError { + throw CancellationError() } catch { logger.error("LiveKit room.connect failed", context: ["error": "\(error)"]) - errorHandler?(error) - if await LocalNetworkPermissionMonitor.shared.shouldSuggestLocalNetworkPermission() { - errorHandler?(ConversationError.localNetworkPermissionRequired) - } - throw error + throw ConversationStartupFailure.room(.connectionFailed(error)) } + let tConnect = Date() + + // Diagnostics โ€” is the capture engine already warm before we publish? + let micEngineWarm = AudioManager.shared.isEngineRunning + let micPrepared = AudioManager.shared.isRecordingAlwaysPreparedMode + // TODO(perf): the engine is cold here (`isEngineRunning == false`) despite + // prepared-mode, which only keeps recording *initialized*. Starting capture + // overlapped with `room.connect` would let this publish reuse a hot engine + // (~415ms saved on device), at the cost of the mic going hot ~800ms earlier + // and needing matching teardown on disconnect/failure. if enableMic { do { try await room.localParticipant.setMicrophone(enabled: true) logger.info("Microphone enabled successfully") } catch { + // `setMicrophone` starts the engine eagerly only to surface + // failures early; WebRTC re-inits recording itself once media + // flows. The errors are deterministic (permission, session + // category), so there's nothing to retry โ€” honor the policy. logger.error("Failed to enable microphone", context: ["error": "\(error)"]) - errorHandler?(error) - - if throwOnMicrophoneFailure { - throw ConversationError.microphoneToggleFailed(error) + if config.microphoneFailureHandling == .throwError { + throw ConversationStartupFailure.microphone( + ConversationError.microphoneToggleFailed(error) + ) } else { + // `.continueWithoutMicrophone`: log and proceed. logger.warning("Continuing without microphone due to error handling policy") } } } + let tMic = Date() + + // Wait for the agent (remote participant) to join before sending init: a + // reliable data message only reaches participants present at publish time, + // so sending into an empty room would silently drop the handshake. + onStartupPhaseChange?(.waitingForAgent(timeout: config.agentJoinTimeout)) + guard await waitForAgentReady(timeout: config.agentJoinTimeout) else { + throw ConversationStartupFailure.agentTimeout + } + + // Sending the conversation_initiation_client_data handshake. + onStartupPhaseChange?(.sendingInitData) + + // A single send is sufficient: LiveKit buffers the data packet until the + // publisher channel opens and errors out if the connection resets, so no + // poll/retry is needed. + let initEvent = ConversationInitEvent(config: config) + do { + try await send(event: .conversationInit(initEvent)) + logger.debug("Conversation init sent") + } catch is CancellationError { + throw CancellationError() + } catch { + logger.warning("Conversation init failed", context: ["error": "\(error)"]) + let convError = error as? ConversationError ?? .connectionFailed(error) + throw ConversationStartupFailure.conversationInit(convError) + } + let tInit = Date() + + func ms(_ from: Date, _ to: Date) -> String { String(format: "%.0f", to.timeIntervalSince(from) * 1000) } + logger.debug("Startup timings", context: [ + "token_ms": ms(tTokenStart, tToken), + "token_perm_ms": ms(tStart, tReady), + "connect_ms": ms(tReady, tConnect), + "mic_ms": ms(tConnect, tMic), + "mic_warm": "\(micEngineWarm)", + "mic_prepared": "\(micPrepared)", + "init_ms": ms(tMic, tInit), + "total_ms": ms(tStart, tInit), + ]) + } + + /// Hop to the actor and fire the (async) disconnect callback. Used by the + /// non-isolated room delegate so it never touches manager state off-actor. + private func notifyDisconnected() async { + await onDisconnected?() } /// Disconnect and tear down. func disconnect() async { onEventReceived = nil onDisconnected = nil - errorHandler = nil onRemoteSpeakingChanged = nil + onStartupPhaseChange = nil - await readinessDelegate?.release() + readinessDelegate?.release() readinessDelegate = nil await room?.disconnect() @@ -266,36 +287,59 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging { eventDelegate = nil } - // MARK: โ€“ Private helpers - - /// Run one timed startup phase: record its duration into `metrics[keyPath:]`, - /// let `CancellationError` propagate unwrapped, and wrap any other error via - /// `failure` (stamping `total`). - @MainActor - private func runPhase( - timing keyPath: WritableKeyPath, - metrics: inout ConversationStartupMetrics, - startTime: Date, - failure: (ConversationError, ConversationStartupMetrics) -> StartupFailure, - _ body: () async throws -> T - ) async throws -> T { - let start = Date() - do { - let result = try await body() - metrics[keyPath: keyPath] = Date().timeIntervalSince(start) - return result - } catch is CancellationError { - metrics[keyPath: keyPath] = Date().timeIntervalSince(start) - metrics.total = Date().timeIntervalSince(startTime) - throw CancellationError() - } catch { - metrics[keyPath: keyPath] = Date().timeIntervalSince(start) - metrics.total = Date().timeIntervalSince(startTime) - throw failure(error as? ConversationError ?? .connectionFailed(error), metrics) + /// Race the delegate's "first remote participant joined" signal against + /// `timeout`. Returns `true` once the agent joins, or `false` if it doesn't + /// arrive in time. Internal to `connect`'s startup sequence. + private func waitForAgentReady(timeout: TimeInterval) async -> Bool { + guard let delegate = readinessDelegate else { return false } + return await withTaskGroup(of: Bool.self) { group in + group.addTask { + do { + try await delegate.awaitRemoteParticipant() + return true + } catch { + return false + } + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return false + } + let first = await group.next()! + group.cancelAll() + return first } } + /// Map ``ConversationConfig`` onto LiveKit connect options, or `nil` to use + /// LiveKit's default ICE behaviour (gather *all* candidate types โ€” host, + /// server-reflexive, and relay). + /// + /// We deliberately keep the full candidate set by default. It's tempting to + /// assume host candidates are useless against a cloud media server, but on + /// real networks (notably IPv6 / CGNAT, where the server-reflexive candidate + /// is redundant with โ€” and suppressed in favour of โ€” a globally routable + /// host candidate) the host candidate is the path that actually connects. + /// Dropping it (`.noHost`) can strand the session on relay-only and time out. + /// + /// ``ConversationConfig/relayOnly`` forces all media through TURN + /// (``IceTransportPolicy/relay``) for networks that require it. + private static func makeConnectOptions(for config: ConversationConfig) -> ConnectOptions? { + guard config.relayOnly else { return nil } + return ConnectOptions(iceTransportPolicy: .relay) + } + + /// Request microphone permission, returning whether it is granted. private func requestMicrophonePermission() async -> Bool { + if Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") == nil { + logger.error( + "NSMicrophoneUsageDescription is missing from your app's Info.plist. " + + "Voice features require this key. Add it to Info.plist or set " + + "INFOPLIST_KEY_NSMicrophoneUsageDescription in your build settings." + ) + return false + } + #if os(macOS) switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: @@ -319,11 +363,20 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging { } #endif } -} -/// The agent joins with an identity prefixed `agent`; other participants don't. -private func isAgentParticipant(_ participant: Participant) -> Bool { - (participant.identity.map { String(describing: $0) } ?? "").hasPrefix("agent") + func send(data: Data) async throws { + guard let room else { + throw ConnectionManagerError.notConnected + } + try await room.localParticipant.publish(data: data, options: Self.reliableDataPublishOptions) + } + + func setMicrophoneMuted(_ muted: Bool) async throws { + guard let room else { + throw ConnectionManagerError.notConnected + } + try await room.localParticipant.setMicrophone(enabled: !muted) + } } // MARK: โ€“ Room event delegate @@ -353,12 +406,12 @@ final class LiveKitRoomEventDelegate: RoomDelegate { } nonisolated func room(_: Room, didUpdateSpeakingParticipants participants: [Participant]) { - // The agent is a remote participant, so any active remote speaker means it's speaking. + // The agent is a remote participant, so any active remote speaker means it is speaking. onRemoteSpeaking(participants.contains { $0 is RemoteParticipant }) } nonisolated func room(_ room: Room, participantDidDisconnect participant: RemoteParticipant) { - guard isAgentParticipant(participant) || room.remoteParticipants.isEmpty else { return } + guard WebRTCConnectionManager.isAgentParticipant(participant) || room.remoteParticipants.isEmpty else { return } Task { [onRemoteDisconnect] in await onRemoteDisconnect() } @@ -393,7 +446,7 @@ final class LiveKitReadinessDelegate: RoomDelegate { func awaitRemoteParticipant() async throws { if let outcome { return try outcome.get() } try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + try await withCheckedThrowingContinuation { cont in if let outcome { cont.resume(with: outcome) } else if Task.isCancelled { @@ -417,20 +470,18 @@ final class LiveKitReadinessDelegate: RoomDelegate { nonisolated func roomDidConnect(_ room: Room) { Task { @MainActor in - if room.remoteParticipants.values.contains(where: isAgentParticipant) { self.markReady() } + if !room.remoteParticipants.isEmpty { self.markReady() } } } - nonisolated func room(_: Room, participantDidConnect participant: RemoteParticipant) { - Task { @MainActor in - if isAgentParticipant(participant) { self.markReady() } - } + nonisolated func room(_: Room, participantDidConnect _: RemoteParticipant) { + Task { @MainActor in self.markReady() } } // MARK: - Private private func markReady() { - logger.debug("Agent joined") + logger.debug("Remote participant joined") finish(.success(())) } diff --git a/Sources/ElevenLabs/Internal/Networking/WebSocketConnectionManager.swift b/Sources/ElevenLabs/Internal/Networking/WebSocketConnectionManager.swift index 72a11102..dfea7eaf 100644 --- a/Sources/ElevenLabs/Internal/Networking/WebSocketConnectionManager.swift +++ b/Sources/ElevenLabs/Internal/Networking/WebSocketConnectionManager.swift @@ -10,10 +10,13 @@ import Foundation /// Used instead of `WebRTCConnectionManager` because the WebRTC transport /// drops rooms with no audio โ€” text-only needs a transport that stays open /// without media. +@MainActor final class WebSocketConnectionManager: WebSocketConnectionManaging { var onEventReceived: (@Sendable (IncomingEvent) -> Void)? var onDisconnected: (() async -> Void)? - var errorHandler: ((Swift.Error?) -> Void)? + + /// Reports startup-phase transitions during `connect`. + var onStartupPhaseChange: ((StartupPhase) -> Void)? private let urlSession: URLSession private let logger: any Logging @@ -29,36 +32,41 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging { urlSession.invalidateAndCancel() } - func connect(auth: ElevenLabsConfiguration, options: ConversationOptions) async throws -> StartupResult { - let startTime = Date() - var metrics = ConversationStartupMetrics() + func connect(auth: ConversationAuth, config: ConversationConfig) async throws { + let endpoints = config.endpoints + + // Authorizing: build/validate the WebSocket URL from `auth`. + onStartupPhaseChange?(.authorizing) let url: URL do { - url = try Self.url(for: auth) + url = try Self.url(for: auth, base: endpoints.textWebSocket, environment: config.environment) } catch { - metrics.total = Date().timeIntervalSince(startTime) let convError = error as? ConversationError ?? .authenticationFailed(error.localizedDescription) - throw StartupFailure.token(convError, metrics) + throw ConversationStartupFailure.token(convError) } + // Connecting: open the socket (the handshake completes on first send). + onStartupPhaseChange?(.connecting) + let task = urlSession.webSocketTask(with: url) self.task = task task.resume() - // The first send awaits the WebSocket handshake internally โ€” - // any connection failure surfaces here. + // Sending the conversation_initiation_client_data handshake. The first + // send awaits the WebSocket handshake internally โ€” any connection + // failure surfaces here. + onStartupPhaseChange?(.sendingInitData) do { - let initEvent = ConversationInitEvent(config: options.toConversationConfig()) + let initEvent = ConversationInitEvent(config: config) try await send(data: EventSerializer.serializeOutgoingEvent(.conversationInit(initEvent))) } catch is CancellationError { tearDownTask(task) throw CancellationError() } catch { tearDownTask(task) - metrics.total = Date().timeIntervalSince(startTime) let convError = error as? ConversationError ?? .connectionFailed(error) - throw StartupFailure.conversationInit(convError, metrics) + throw ConversationStartupFailure.conversationInit(convError) } // Socket is up and the init message is sent. Start consuming responses. @@ -66,10 +74,6 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging { guard let self, let task else { return } await receiveLoop(task: task) } - - metrics.conversationInitAttempts = 1 - metrics.total = Date().timeIntervalSince(startTime) - return StartupResult(agentId: auth.agentId, metrics: metrics) } func send(data: Data) async throws { @@ -82,7 +86,7 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging { func disconnect() async { onEventReceived = nil onDisconnected = nil - errorHandler = nil + onStartupPhaseChange = nil receiveTask?.cancel() receiveTask = nil @@ -112,20 +116,28 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging { } catch { guard !Task.isCancelled else { return } self.task = nil - errorHandler?(error) + logger.error("WebSocket receive failed", context: ["error": "\(error)"]) await onDisconnected?() return } } } - static func url(for auth: ElevenLabsConfiguration) throws -> URL { + static func url(for auth: ConversationAuth, base: URL, environment: String? = nil) throws -> URL { switch auth.authSource { case let .publicAgentId(agentId): - var components = URLComponents(string: ConnectionConstants.textConversationUrl) - components?.queryItems = [URLQueryItem(name: "agent_id", value: agentId)] - guard let url = components?.url else { - throw ConversationError.authenticationFailed("Invalid conversation URL") + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { + throw ConversationError.invalidURL + } + var queryItems = [ + URLQueryItem(name: "agent_id", value: agentId) + ] + if let environment { + queryItems.append(URLQueryItem(name: "environment", value: environment)) + } + components.queryItems = queryItems + guard let url = components.url else { + throw ConversationError.invalidURL } return url @@ -135,7 +147,7 @@ final class WebSocketConnectionManager: WebSocketConnectionManaging { } return url - case .conversationToken, .customTokenProvider: + case .conversationToken: throw ConversationError.authenticationFailed( "Text-only conversations require a public agent ID or signed WebSocket URL." ) diff --git a/Sources/ElevenLabs/Internal/Utilities/SDKLogger.swift b/Sources/ElevenLabs/Internal/Utilities/SDKLogger.swift index 8d1b5bde..91260bd1 100644 --- a/Sources/ElevenLabs/Internal/Utilities/SDKLogger.swift +++ b/Sources/ElevenLabs/Internal/Utilities/SDKLogger.swift @@ -37,16 +37,18 @@ extension Logging { struct SDKLogger: Logging { private let subsystem: String private let category: String - private let logLevel: ElevenLabs.LogLevel + /// The fixed threshold for this logger. Set from `ConversationConfig.logLevel` + /// via `Dependencies`; defaults to `.warning` for loggers created without one. + private let logLevel: LogLevel init( subsystem: String = "com.elevenlabs.sdk", category: String = "ElevenLabs", - logLevel: ElevenLabs.LogLevel = .info + levelOverride: LogLevel? = nil ) { self.subsystem = subsystem self.category = category - self.logLevel = logLevel + self.logLevel = (levelOverride ?? .warning).sdkVerbosity } /// Helper to log safely @@ -54,7 +56,8 @@ struct SDKLogger: Logging { let prefix = "[ElevenLabs]" let finalMessage: String if let context, !context.isEmpty { - // ensure stable order in logs + // Sort by key so a given log line's context is stably ordered rather + // than reflecting `Dictionary`'s nondeterministic iteration order. let contextString = context .sorted { $0.key < $1.key } .map { "\($0.key)=\($0.value)" } diff --git a/Tests/ElevenLabsTests/Mocks/MockWebRTCConnectionManager.swift b/Tests/ElevenLabsTests/Mocks/MockWebRTCConnectionManager.swift index 86753254..a2fdb3d3 100644 --- a/Tests/ElevenLabsTests/Mocks/MockWebRTCConnectionManager.swift +++ b/Tests/ElevenLabsTests/Mocks/MockWebRTCConnectionManager.swift @@ -2,6 +2,14 @@ import Foundation import LiveKit +/// Scriptable test double for `WebRTCConnectionManaging`. +/// +/// `connect` mirrors the externally observable contract of the production +/// manager: it resolves a token, connects a room, waits for the agent, then +/// sends the conversation-init handshake โ€” surfacing each stage's failure as a +/// `StartupFailure` so `Conversation`'s `handleStartupFailure` path is exercised. +/// Tests drive the agent-ready step with `succeedAgentReady()` / `timeoutAgentReady()`. +@MainActor final class MockWebRTCConnectionManager: WebRTCConnectionManaging { enum Error: Swift.Error { case connectionFailed @@ -11,6 +19,13 @@ final class MockWebRTCConnectionManager: WebRTCConnectionManaging { var onDisconnected: (() async -> Void)? var onEventReceived: (@Sendable (IncomingEvent) -> Void)? var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? + var onStartupPhaseChange: ((StartupPhase) -> Void)? + + /// When true, `connect` synthesizes a `conversation_initiation_metadata` + /// event on success so the production startup gate (which blocks until the + /// metadata arrives) completes. Set to false to drive metadata manually. + var autoDeliverMetadata = true + var metadataConversationId = "mock-conversation-id" var room: Room? @@ -18,79 +33,76 @@ final class MockWebRTCConnectionManager: WebRTCConnectionManaging { var agentAudioTrack: RemoteAudioTrack? var isMicrophoneMuted = true - var errorHandler: ((Swift.Error?) -> Void)? - + /// Inject a failure at the token-resolution stage. + var tokenError: Swift.Error? + /// Inject a failure at the room-connect stage. var shouldFailConnection = false var connectionError: Swift.Error = Error.connectionFailed - var tokenError: ConversationError? + /// Inject a failure at the conversation-init publish stage. var publishError: Swift.Error? var microphoneError: Swift.Error? private(set) var connectCallCount = 0 private(set) var disconnectCallCount = 0 - private(set) var lastNetworkConfiguration: LiveKitNetworkConfiguration = .default + private(set) var lastAuth: ConversationAuth? + private(set) var lastConfig: ConversationConfig? private(set) var lastWaitTimeout: TimeInterval = 0 private(set) var publishedPayloads: [Data] = [] - private var waitContinuation: CheckedContinuation? - private var pendingWaitResult: AgentReadyWaitResult? - - /// Simulates the full WebRTC startup (token โ†’ room โ†’ agent โ†’ init), driven by - /// the `tokenError`/`shouldFailConnection`/`publishError` flags and the - /// agent-ready continuation (`succeedAgentReady`/`timeoutAgentReady`). - @MainActor - func connect( - auth: ElevenLabsConfiguration, - options: ConversationOptions, - onStartupStateChange: @escaping (ConversationStartupState) -> Void - ) async throws -> StartupResult { + private var waitContinuation: CheckedContinuation? + private var pendingWaitResult: Bool? + + func connect(auth: ConversationAuth, config: ConversationConfig) async throws { connectCallCount += 1 - lastNetworkConfiguration = options.networkConfiguration - var metrics = ConversationStartupMetrics() + lastAuth = auth + lastConfig = config - onStartupStateChange(.resolvingToken) + // Token stage. if let tokenError { - throw StartupFailure.token(tokenError, metrics) + let convError = tokenError as? ConversationError ?? .authenticationFailed("\(tokenError)") + throw ConversationStartupFailure.token(convError) } - onStartupStateChange(.connectingRoom) + // Room-connect stage. if shouldFailConnection { - errorHandler?(connectionError) - throw StartupFailure.room(connectionError as? ConversationError ?? .connectionFailed(connectionError), metrics) + let convError = connectionError as? ConversationError ?? .connectionFailed(connectionError) + throw ConversationStartupFailure.room(convError) } room = Room() - onStartupStateChange(.waitingForAgent(timeout: options.startupConfiguration.agentReadyTimeout)) - switch await waitForAgentReady(timeout: options.startupConfiguration.agentReadyTimeout) { - case let .success(elapsed): - metrics.agentReady = elapsed - onStartupStateChange(.agentReady(ConversationAgentReadyReport(elapsed: elapsed))) - case let .timedOut(elapsed): - metrics.agentReady = elapsed - throw StartupFailure.agentTimeout(metrics) + // Wait for the agent (driven by the test via succeed/timeout helpers). + guard await waitForAgentReady(timeout: config.agentJoinTimeout) else { + throw ConversationStartupFailure.agentTimeout } - onStartupStateChange(.sendingConversationInit(attempt: 1)) - do { - try await send(event: .conversationInit(ConversationInitEvent(config: options.toConversationConfig()))) - } catch { - throw StartupFailure.conversationInit(error as? ConversationError ?? .connectionFailed(error), metrics) + // Conversation-init publish stage. + if let publishError { + let convError = publishError as? ConversationError ?? .connectionFailed(publishError) + throw ConversationStartupFailure.conversationInit(convError) } - metrics.conversationInitAttempts = 1 + let initEvent = ConversationInitEvent(config: config) + publishedPayloads.append(try EventSerializer.serializeOutgoingEvent(.conversationInit(initEvent))) - return StartupResult(agentId: auth.agentId, metrics: metrics) + deliverMetadataIfNeeded() } func disconnect() async { disconnectCallCount += 1 onEventReceived = nil onDisconnected = nil - errorHandler = nil onRemoteSpeakingChanged = nil + onStartupPhaseChange = nil room = nil + // Mirror production: disconnect releases any in-flight agent-ready wait + // (the readiness delegate is cancelled), which `waitForAgentReady` + // observes as `false`. + if let continuation = waitContinuation { + waitContinuation = nil + continuation.resume(returning: false) + } } - func waitForAgentReady(timeout: TimeInterval) async -> AgentReadyWaitResult { + func waitForAgentReady(timeout: TimeInterval) async -> Bool { lastWaitTimeout = timeout if let pending = pendingWaitResult { pendingWaitResult = nil @@ -107,7 +119,6 @@ final class MockWebRTCConnectionManager: WebRTCConnectionManaging { throw ConnectionManagerError.notConnected } if let publishError { - errorHandler?(publishError) throw publishError } publishedPayloads.append(data) @@ -115,10 +126,9 @@ final class MockWebRTCConnectionManager: WebRTCConnectionManaging { func setMicrophoneMuted(_ muted: Bool) async throws { guard room != nil else { - throw WebRTCConnectionManagerError.roomUnavailable + throw ConnectionManagerError.notConnected } if let microphoneError { - errorHandler?(microphoneError) throw microphoneError } isMicrophoneMuted = muted @@ -127,18 +137,27 @@ final class MockWebRTCConnectionManager: WebRTCConnectionManaging { // MARK: - Helpers func receive(data: Data) { - handleIncomingData(data, logger: SDKLogger(logLevel: .error)) + handleIncomingData(data, logger: SDKLogger(levelOverride: .error)) + } + + private func deliverMetadataIfNeeded() { + guard autoDeliverMetadata else { return } + onEventReceived?(.conversationMetadata(ConversationMetadataEvent( + conversationId: metadataConversationId, + agentOutputAudioFormat: "pcm_16000", + userInputAudioFormat: "pcm_16000" + ))) } - func succeedAgentReady(elapsed: TimeInterval = 0.1) { - resumeWait(with: .success(elapsed: elapsed)) + func succeedAgentReady() { + resumeWait(with: true) } - func timeoutAgentReady(elapsed: TimeInterval = 0.1) { - resumeWait(with: .timedOut(elapsed: elapsed)) + func timeoutAgentReady() { + resumeWait(with: false) } - private func resumeWait(with result: AgentReadyWaitResult) { + private func resumeWait(with result: Bool) { if let continuation = waitContinuation { waitContinuation = nil continuation.resume(returning: result) diff --git a/Tests/ElevenLabsTests/Mocks/MockWebSocketConnectionManager.swift b/Tests/ElevenLabsTests/Mocks/MockWebSocketConnectionManager.swift index 72f6ae81..76fb4c0a 100644 --- a/Tests/ElevenLabsTests/Mocks/MockWebSocketConnectionManager.swift +++ b/Tests/ElevenLabsTests/Mocks/MockWebSocketConnectionManager.swift @@ -1,13 +1,20 @@ @testable import ElevenLabs import Foundation +@MainActor final class MockWebSocketConnectionManager: WebSocketConnectionManaging { var onEventReceived: (@Sendable (IncomingEvent) -> Void)? var onDisconnected: (() async -> Void)? - var errorHandler: ((Swift.Error?) -> Void)? + var onStartupPhaseChange: ((StartupPhase) -> Void)? - var connectError: Error? - var sendError: Error? + var connectError: Swift.Error? + var sendError: Swift.Error? + + /// When true, `connect` synthesizes a `conversation_initiation_metadata` + /// event on success so the production startup gate (which blocks until the + /// metadata arrives) completes. Set to false to drive metadata manually. + var autoDeliverMetadata = true + var metadataConversationId = "mock-conversation-id" private(set) var connectCallCount = 0 private(set) var disconnectCallCount = 0 @@ -15,49 +22,45 @@ final class MockWebSocketConnectionManager: WebSocketConnectionManaging { private(set) var sentPayloads: [Data] = [] private(set) var isConnected = false - func connect(auth: ElevenLabsConfiguration, options: ConversationOptions) async throws -> StartupResult { + func connect(auth: ConversationAuth, config: ConversationConfig) async throws { connectCallCount += 1 - let startTime = Date() - var metrics = ConversationStartupMetrics() do { - lastConnectedURL = try WebSocketConnectionManager.url(for: auth) + lastConnectedURL = try WebSocketConnectionManager.url( + for: auth, + base: config.endpoints.textWebSocket, + environment: config.environment + ) } catch { - metrics.total = Date().timeIntervalSince(startTime) let convError = error as? ConversationError ?? .authenticationFailed(error.localizedDescription) - throw StartupFailure.token(convError, metrics) + throw ConversationStartupFailure.token(convError) } if let connectError { - errorHandler?(connectError) - metrics.total = Date().timeIntervalSince(startTime) let convError = connectError as? ConversationError ?? .connectionFailed(connectError) - throw StartupFailure.conversationInit(convError, metrics) + throw ConversationStartupFailure.conversationInit(convError) } isConnected = true do { - let initEvent = ConversationInitEvent(config: options.toConversationConfig()) + let initEvent = ConversationInitEvent(config: config) try await send(data: EventSerializer.serializeOutgoingEvent(.conversationInit(initEvent))) } catch is CancellationError { throw CancellationError() } catch { - metrics.total = Date().timeIntervalSince(startTime) let convError = error as? ConversationError ?? .connectionFailed(error) - throw StartupFailure.conversationInit(convError, metrics) + throw ConversationStartupFailure.conversationInit(convError) } - metrics.conversationInitAttempts = 1 - metrics.total = Date().timeIntervalSince(startTime) - return StartupResult(agentId: auth.agentId, metrics: metrics) + deliverMetadataIfNeeded() } func disconnect() async { disconnectCallCount += 1 onEventReceived = nil onDisconnected = nil - errorHandler = nil + onStartupPhaseChange = nil isConnected = false } @@ -66,13 +69,21 @@ final class MockWebSocketConnectionManager: WebSocketConnectionManaging { throw ConnectionManagerError.notConnected } if let sendError { - errorHandler?(sendError) throw sendError } sentPayloads.append(data) } func receive(data: Data) { - handleIncomingData(data, logger: SDKLogger(logLevel: .error)) + handleIncomingData(data, logger: SDKLogger(levelOverride: .error)) + } + + private func deliverMetadataIfNeeded() { + guard autoDeliverMetadata else { return } + onEventReceived?(.conversationMetadata(ConversationMetadataEvent( + conversationId: metadataConversationId, + agentOutputAudioFormat: "pcm_16000", + userInputAudioFormat: "pcm_16000" + ))) } } diff --git a/Tests/ElevenLabsTests/Mocks/TestDependencyProvider.swift b/Tests/ElevenLabsTests/Mocks/TestDependencyProvider.swift index d9ca95b0..81627fee 100644 --- a/Tests/ElevenLabsTests/Mocks/TestDependencyProvider.swift +++ b/Tests/ElevenLabsTests/Mocks/TestDependencyProvider.swift @@ -15,6 +15,6 @@ final class TestDependencyProvider: ConversationDependencyProvider { ) { self.webRTCConnectionManager = webRTCConnectionManager ?? MockWebRTCConnectionManager() self.webSocketConnectionManager = webSocketConnectionManager ?? MockWebSocketConnectionManager() - logger = SDKLogger(logLevel: .error) + logger = SDKLogger(levelOverride: .error) } } diff --git a/Tests/ElevenLabsTests/Unit/LiveKitRoomEventDelegateTests.swift b/Tests/ElevenLabsTests/Unit/LiveKitRoomEventDelegateTests.swift index 43eb1062..6b5c58fa 100644 --- a/Tests/ElevenLabsTests/Unit/LiveKitRoomEventDelegateTests.swift +++ b/Tests/ElevenLabsTests/Unit/LiveKitRoomEventDelegateTests.swift @@ -2,7 +2,7 @@ import LiveKit import XCTest -/// Verifies delegate callbacks implement correct `RoomDelegate` methods +/// Verifies delegate callbacks implement the `RoomDelegate` methods LiveKit actually calls. final class LiveKitRoomEventDelegateTests: XCTestCase { private func makeDelegate( onData: @escaping @Sendable (Data) -> Void = { _ in }, From b2b4387df68e28a3b592f604b1d1d393065cdd80 Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:30:17 +0100 Subject: [PATCH 6/8] feat(audio): unify MicrophoneMuteMode, fix AudioManager.shared save/restore - AudioPipelineConfiguration: MicrophoneMuteMode is no longer a LiveKit re-export -- it's the SDK's own 4-case enum (.inputMixer/.restart/ .voiceProcessing/.software(speechThreshold:)), folding in what used to be 4 separate fields (microphoneMuteMode/useSoftwareMute/ mutedSpeechThreshold + the onSpeechActivity/onMutedSpeech callback split). recordingAlwaysPrepared opt-out is removed -- engine pre-warm is now unconditional. LiveKit's SpeechActivityEvent no longer leaks into this public config surface. - SoftwareMuteProcessor: onMutedSpeech(MutedSpeechEvent) (throttled, periodic, level-based) becomes onSpeakingWhileMutedChange(Bool) (fires once on the started/ended edge of a hangover-latched speech segment, including on unmute mid-segment). mutedSpeechThrottleInSeconds is gone. - ConversationAudioManager: configure(with:) now snapshots and restores AudioManager.shared's capturePostProcessingDelegate/ isVoiceProcessingBypassed/isVoiceProcessingAGCEnabled around this instance's lifetime (guarded by an identity check on restore) instead of unconditionally clearing them on cleanup -- fixes clobbering another component that took over the process-wide slot. setRecordingAlwaysPreparedMode is now awaited inside configure() (called before WebRTCConnectionManager.connect) instead of fired in the background at init -- this adds a new async hop to the front of the startup critical path; worth a real-device timing check given this SDK's startup-latency history, but not blocking this PR. --- .../ConversationAudioManager.swift | 212 ++++++++++-------- .../Utilities/SoftwareMuteProcessor.swift | 56 +++-- .../AudioPipelineConfiguration.swift | 89 +++----- .../Unit/SoftwareMuteProcessorTests.swift | 71 ++++-- 4 files changed, 243 insertions(+), 185 deletions(-) diff --git a/Sources/ElevenLabs/Internal/Conversation/ConversationAudioManager.swift b/Sources/ElevenLabs/Internal/Conversation/ConversationAudioManager.swift index 786ce9e4..bf064bf4 100644 --- a/Sources/ElevenLabs/Internal/Conversation/ConversationAudioManager.swift +++ b/Sources/ElevenLabs/Internal/Conversation/ConversationAudioManager.swift @@ -4,12 +4,11 @@ import Foundation import LiveKit #endif -/// Manages audio device configuration and speech activity handling for conversations. -/// Encapsulates all AudioManager interactions to keep Conversation class focused on conversation logic. +/// Owns this conversation's `AudioManager.shared` configuration (mute mode, voice +/// processing, capture pre-warm) and speech-activity handling, keeping +/// `Conversation` focused on conversation logic. @MainActor final class ConversationAudioManager { - private(set) var audioDevices: [AudioDevice] = [] - private(set) var selectedAudioDeviceID: String = "" private(set) var softwareMuteProcessor: SoftwareMuteProcessor? private let audioManager = AudioManager.shared @@ -17,134 +16,130 @@ final class ConversationAudioManager { private var audioSpeechHandlerInstalled = false private let logger: any Logging - /// Callback when audio devices list changes - var onDevicesChanged: (([AudioDevice]) -> Void)? - - /// Callback when selected device changes - var onSelectedDeviceChanged: ((String) -> Void)? + // Snapshots of process-global `AudioManager.shared` state this instance + // overwrote in `configure`, so `cleanup`/`deinit` can restore it instead of + // leaking settings across sessions or clobbering the host app's values. A + // `nil` entry means this instance never changed that setting. + // + // `previousCaptureDelegate` is only meaningful while `softwareMuteProcessor` + // is non-nil. + private var previousCaptureDelegate: AudioCustomProcessingDelegate? + private var previousVoiceProcessingBypassed: Bool? + private var previousVoiceProcessingAGCEnabled: Bool? init(logger: any Logging) { self.logger = logger - audioDevices = audioManager.inputDevices - selectedAudioDeviceID = audioManager.inputDevice.deviceId - setupInitialConfiguration() } deinit { - // Reset callbacks directly since we can't call MainActor methods from deinit - audioManager.onDeviceUpdate = nil + // Best-effort restore if torn down without a clean `cleanup()`. We can't + // call MainActor methods here, but these `AudioManager.shared` accessors + // are safe off the main actor. if audioSpeechHandlerInstalled { audioManager.onMutedSpeechActivity = previousSpeechActivityHandler } + // Only revert the capture delegate if ours is still the installed one; a + // process-wide last-write-wins slot means something else may have taken + // over after us, and we must not stomp that. + if let processor = softwareMuteProcessor, + audioManager.capturePostProcessingDelegate.map({ $0 as AnyObject }) === processor + { + audioManager.capturePostProcessingDelegate = previousCaptureDelegate + } + if let bypass = previousVoiceProcessingBypassed { + audioManager.isVoiceProcessingBypassed = bypass + } + if let agc = previousVoiceProcessingAGCEnabled { + audioManager.isVoiceProcessingAGCEnabled = agc + } } // MARK: - Configuration /// Apply audio pipeline configuration from conversation options. - func configure(with options: ConversationOptions) async { - let config = options.audioConfiguration + /// + /// This is the single configuration entry point: it establishes the baseline + /// device state (mute mode + engine pre-warm) and applies any caller overrides. + func configure( + with config: ConversationConfig, + onSpeakingWhileMutedChange: @escaping @Sendable (Bool) -> Void + ) async { + let audioConfig = config.audioConfiguration + let muteMode = audioConfig?.microphoneMuteMode ?? .inputMixer - if let mode = config?.microphoneMuteMode { - do { - try audioManager.set(microphoneMuteMode: mode) - } catch { - logger.warning("Failed to set microphone mute mode", context: ["error": "\(error)"]) - } + do { + try audioManager.set(microphoneMuteMode: muteMode.toLiveKit()) + } catch { + logger.warning("Failed to set microphone mute mode", context: ["error": "\(error)"]) } - if let bypass = config?.voiceProcessingBypassed { + // Snapshot before overwriting so `cleanup` can restore the prior value + // rather than leaking our override into later sessions / other consumers. + if let bypass = audioConfig?.voiceProcessingBypassed { + previousVoiceProcessingBypassed = audioManager.isVoiceProcessingBypassed audioManager.isVoiceProcessingBypassed = bypass } - if let agc = config?.voiceProcessingAGCEnabled { + if let agc = audioConfig?.voiceProcessingAGCEnabled { + previousVoiceProcessingAGCEnabled = audioManager.isVoiceProcessingAGCEnabled audioManager.isVoiceProcessingAGCEnabled = agc } - if let prepared = config?.recordingAlwaysPrepared { - do { - try await audioManager.setRecordingAlwaysPreparedMode(prepared) - } catch { - logger.warning("Failed to set recording always prepared mode", context: ["error": "\(error)"]) - } + // Pre-warm the capture engine via "recording always prepared" mode so the + // first `setMicrophone(enabled:)` reuses a warm engine. Without it the + // engine cold-starts at publish time and the first VPIO init fails with + // audio-engine error -4010 (reproducible on the simulator after a fresh + // permission grant). + // + // Intentionally NOT reverted in `cleanup`: keeping it prepared lets + // back-to-back conversations reuse the warm engine, and is a benign global. + do { + try await audioManager.setRecordingAlwaysPreparedMode(true) + } catch { + logger.warning("Failed to set recording always prepared mode", context: ["error": "\(error)"]) } - configureSpeechHandler(options: options) - configureSoftwareMuteProcessor(options: options) + configureSpeechHandler(onSpeakingWhileMutedChange: onSpeakingWhileMutedChange) + configureSoftwareMuteProcessor(muteMode: muteMode, onSpeakingWhileMutedChange: onSpeakingWhileMutedChange) } /// Cleanup audio state when conversation ends. func cleanup() { cleanupSpeechHandler() cleanupSoftwareMuteProcessor() + restoreVoiceProcessingState() } // MARK: - Private - private func setupInitialConfiguration() { - // Set initial microphone mute mode - do { - try audioManager.set(microphoneMuteMode: .inputMixer) - } catch { - logger.warning("Failed to set initial microphone mute mode", context: ["error": "\(error)"]) - } - - // Set recording always prepared mode asynchronously - Task { [weak self] in - guard let self else { return } - do { - try await audioManager.setRecordingAlwaysPreparedMode(true) - } catch { - logger.warning("Failed to set recording always prepared mode", context: ["error": "\(error)"]) - } - } - - // Setup device change observer - audioManager.onDeviceUpdate = { [weak self] _ in - Task { @MainActor in - guard let self else { return } - self.audioDevices = self.audioManager.inputDevices - self.selectedAudioDeviceID = self.audioManager.defaultInputDevice.deviceId - self.onDevicesChanged?(self.audioDevices) - self.onSelectedDeviceChanged?(self.selectedAudioDeviceID) - } + private func configureSpeechHandler(onSpeakingWhileMutedChange: @escaping @Sendable (Bool) -> Void) { + if !audioSpeechHandlerInstalled { + previousSpeechActivityHandler = audioManager.onMutedSpeechActivity + audioSpeechHandlerInstalled = true } - } - - private func configureSpeechHandler(options: ConversationOptions) { - let config = options.audioConfiguration - let needsSpeechHandler = (config?.onSpeechActivity != nil) || (options.onSpeechActivity != nil) - - if needsSpeechHandler { - if !audioSpeechHandlerInstalled { - previousSpeechActivityHandler = audioManager.onMutedSpeechActivity - audioSpeechHandlerInstalled = true - } - audioManager.onMutedSpeechActivity = { _, event in - // Handlers are @Sendable, they manage their own synchronization - if let handler = config?.onSpeechActivity { - handler(event) - } - if let handler = options.onSpeechActivity { - handler(event) - } - } - } else if audioSpeechHandlerInstalled { - cleanupSpeechHandler() + audioManager.onMutedSpeechActivity = { _, event in + // Handlers are @Sendable, they manage their own synchronization. + onSpeakingWhileMutedChange(event == .started) } } - private func configureSoftwareMuteProcessor(options: ConversationOptions) { - guard options.audioConfiguration?.useSoftwareMute == true else { + private func configureSoftwareMuteProcessor( + muteMode: MicrophoneMuteMode, + onSpeakingWhileMutedChange: @escaping @Sendable (Bool) -> Void + ) { + guard case let .software(speechThreshold) = muteMode else { return } - let audioConfig = options.audioConfiguration - softwareMuteProcessor = SoftwareMuteProcessor( - onMutedSpeech: audioConfig?.onMutedSpeech, - mutedSpeechThresholdInDb: audioConfig?.mutedSpeechThreshold ?? -35, - mutedSpeechThrottleInSeconds: 3.0 + let processor = SoftwareMuteProcessor( + onSpeakingWhileMutedChange: onSpeakingWhileMutedChange, + mutedSpeechThresholdInDb: speechThreshold ) - AudioManager.shared.capturePostProcessingDelegate = softwareMuteProcessor + softwareMuteProcessor = processor + // Snapshot any pre-existing delegate so `cleanup` restores it rather than + // nilling out a delegate this instance never owned. + previousCaptureDelegate = audioManager.capturePostProcessingDelegate + audioManager.capturePostProcessingDelegate = processor } private func cleanupSpeechHandler() { @@ -156,7 +151,44 @@ final class ConversationAudioManager { } private func cleanupSoftwareMuteProcessor() { - AudioManager.shared.capturePostProcessingDelegate = nil + guard let processor = softwareMuteProcessor else { return } + // Restore the delegate we displaced โ€” but only if ours is still the one + // installed. The slot is process-wide last-write-wins, so if another + // component set its own delegate after us, leave that in place. + if audioManager.capturePostProcessingDelegate.map({ $0 as AnyObject }) === processor { + audioManager.capturePostProcessingDelegate = previousCaptureDelegate + } + previousCaptureDelegate = nil softwareMuteProcessor = nil } + + /// Restore the VPIO flags this instance overrode in `configure`. The capture + /// pre-warm (`setRecordingAlwaysPreparedMode(true)`) is intentionally left + /// enabled process-wide (see `configure`), so it is not restored here. + private func restoreVoiceProcessingState() { + if let bypass = previousVoiceProcessingBypassed { + audioManager.isVoiceProcessingBypassed = bypass + previousVoiceProcessingBypassed = nil + } + if let agc = previousVoiceProcessingAGCEnabled { + audioManager.isVoiceProcessingAGCEnabled = agc + previousVoiceProcessingAGCEnabled = nil + } + } } + +// MARK: - LiveKit mapping + +private extension MicrophoneMuteMode { + /// Maps to LiveKit's hardware mute mode. Software mute has no LiveKit + /// equivalent โ€” the track is kept open and muting happens in + /// `SoftwareMuteProcessor`, so the engine runs with `.inputMixer` underneath. + func toLiveKit() -> LiveKit.MicrophoneMuteMode { + switch self { + case .voiceProcessing: .voiceProcessing + case .restart: .restart + case .inputMixer, .software: .inputMixer + } + } +} + diff --git a/Sources/ElevenLabs/Internal/Utilities/SoftwareMuteProcessor.swift b/Sources/ElevenLabs/Internal/Utilities/SoftwareMuteProcessor.swift index 53ec820f..7029f78c 100644 --- a/Sources/ElevenLabs/Internal/Utilities/SoftwareMuteProcessor.swift +++ b/Sources/ElevenLabs/Internal/Utilities/SoftwareMuteProcessor.swift @@ -19,35 +19,49 @@ final class SoftwareMuteProcessor: NSObject, @unchecked Sendable, AudioCustomPro private var lock = os_unfair_lock_s() private var isMuted: Bool = false - private var lastNotificationTime: Date = .distantPast private var consecutiveAboveCount: Int = 0 private var consecutiveBelowCount: Int = 0 private var hangoverLatched: Bool = false - private let onMutedSpeech: (@Sendable (MutedSpeechEvent) -> Void)? + private let onSpeakingWhileMutedChange: (@Sendable (Bool) -> Void)? private let mutedSpeechThresholdInDb: Float - private let mutedSpeechThrottleInSeconds: TimeInterval init( - onMutedSpeech: (@Sendable (MutedSpeechEvent) -> Void)?, - mutedSpeechThresholdInDb: Float = -35, - mutedSpeechThrottleInSeconds: TimeInterval = 3.0 + onSpeakingWhileMutedChange: (@Sendable (Bool) -> Void)?, + mutedSpeechThresholdInDb: Float = -35 ) { - self.onMutedSpeech = onMutedSpeech + self.onSpeakingWhileMutedChange = onSpeakingWhileMutedChange self.mutedSpeechThresholdInDb = mutedSpeechThresholdInDb - self.mutedSpeechThrottleInSeconds = mutedSpeechThrottleInSeconds + } + + /// The current software-gate mute state. The source of truth for + /// `.software` mute mode, where the capture track stays open and the + /// hardware mic flag would misleadingly read as unmuted. + var muted: Bool { + os_unfair_lock_lock(&lock) + defer { os_unfair_lock_unlock(&lock) } + return isMuted } func setMuted(_ muted: Bool) { + var fireEnded = false os_unfair_lock_lock(&lock) if isMuted != muted { + // Unmuting while speech was latched ends the muted-speech segment. + if !muted, hangoverLatched { + fireEnded = true + } consecutiveAboveCount = 0 consecutiveBelowCount = 0 hangoverLatched = false } isMuted = muted os_unfair_lock_unlock(&lock) + + if fireEnded { + DispatchQueue.main.async { self.onSpeakingWhileMutedChange?(false) } + } } func audioProcessingProcess(audioBuffer: LKAudioBuffer) { @@ -73,37 +87,35 @@ final class SoftwareMuteProcessor: NSObject, @unchecked Sendable, AudioCustomPro let levelActive = db > mutedSpeechThresholdInDb - var shouldFire = false - var fireLevel: Float = 0 + var fireStarted = false + var fireEnded = false os_unfair_lock_lock(&lock) if levelActive { consecutiveBelowCount = 0 consecutiveAboveCount += 1 - if consecutiveAboveCount >= Hangover.buffersAboveToConfirm { + if consecutiveAboveCount >= Hangover.buffersAboveToConfirm, !hangoverLatched { hangoverLatched = true + fireStarted = true } } else { consecutiveAboveCount = 0 consecutiveBelowCount += 1 - if consecutiveBelowCount >= Hangover.buffersBelowToClear { + if consecutiveBelowCount >= Hangover.buffersBelowToClear, hangoverLatched { hangoverLatched = false consecutiveBelowCount = 0 + fireEnded = true } } + os_unfair_lock_unlock(&lock) - if hangoverLatched, levelActive { - let now = Date() - if now.timeIntervalSince(lastNotificationTime) > mutedSpeechThrottleInSeconds { - lastNotificationTime = now - shouldFire = true - fireLevel = db + if fireStarted { + DispatchQueue.main.async { + self.onSpeakingWhileMutedChange?(true) } } - os_unfair_lock_unlock(&lock) - - if shouldFire { + if fireEnded { DispatchQueue.main.async { - self.onMutedSpeech?(.init(audioLevel: fireLevel)) + self.onSpeakingWhileMutedChange?(false) } } diff --git a/Sources/ElevenLabs/Public/Conversation/AudioPipelineConfiguration.swift b/Sources/ElevenLabs/Public/Conversation/AudioPipelineConfiguration.swift index 83598c86..6c6f9c00 100644 --- a/Sources/ElevenLabs/Public/Conversation/AudioPipelineConfiguration.swift +++ b/Sources/ElevenLabs/Public/Conversation/AudioPipelineConfiguration.swift @@ -1,77 +1,60 @@ import Foundation -import LiveKit - -/// Event indicating the user is speaking while the microphone is muted. -public struct MutedSpeechEvent: Sendable { - /// Audio level that triggered the event in Db - public let audioLevel: Float - - public init(audioLevel: Float) { - self.audioLevel = audioLevel - } -} /// Configures microphone pipeline and voice activity reporting exposed by the SDK. public struct AudioPipelineConfiguration: Sendable { - /// Override the microphone mute strategy. Defaults to `.inputMixer` to match previous SDK behaviour. + /// Override the microphone mute strategy. Defaults to `.inputMixer`. public var microphoneMuteMode: MicrophoneMuteMode? - /// Keep the recording engine warm to avoid first-spoken-word latency. Defaults to `true`. - public var recordingAlwaysPrepared: Bool? - /// Bypass WebRTC voice processing (AEC/NS/VAD). Leave `nil` to preserve system defaults. public var voiceProcessingBypassed: Bool? /// Toggle Auto Gain Control. Leave `nil` to preserve system defaults. public var voiceProcessingAGCEnabled: Bool? - /// Observe LiveKit speech activity events while the microphone is muted. - public var onSpeechActivity: (@Sendable (SpeechActivityEvent) -> Void)? - - /// Enable software mute. With software mute, the microphone will stay open when `setMuted(true(` is used, but - /// all captured audio data will be zeroed out. By enabling software mute you can set the `onMutedSpeech` callback - /// and receive callback events when users speak while the agent is muted. - public var useSoftwareMute: Bool? - - /// Called when local speech is detected while the microphone is muted. - /// This uses local audio processing and works reliably with `.inputMixer` mode. - /// Use this to show "You're speaking while muted" indicators. - public var onMutedSpeech: (@Sendable (MutedSpeechEvent) -> Void)? - - /// Audio level in dB where speech is detected. Default: -35 dB (see `SoftwareMuteProcessor`). - /// Increase to require louder speech, decrease for more sensitivity. - public var mutedSpeechThreshold: Float? - public init( microphoneMuteMode: MicrophoneMuteMode? = .inputMixer, - recordingAlwaysPrepared: Bool? = true, voiceProcessingBypassed: Bool? = nil, - voiceProcessingAGCEnabled: Bool? = nil, - onSpeechActivity: (@Sendable (SpeechActivityEvent) -> Void)? = nil, - useSoftwareMute: Bool? = nil, - onMutedSpeech: (@Sendable (MutedSpeechEvent) -> Void)? = nil, - mutedSpeechThreshold: Float? = nil + voiceProcessingAGCEnabled: Bool? = nil ) { self.microphoneMuteMode = microphoneMuteMode - self.recordingAlwaysPrepared = recordingAlwaysPrepared self.voiceProcessingBypassed = voiceProcessingBypassed self.voiceProcessingAGCEnabled = voiceProcessingAGCEnabled - self.onSpeechActivity = onSpeechActivity - self.useSoftwareMute = useSoftwareMute - self.onMutedSpeech = onMutedSpeech - self.mutedSpeechThreshold = mutedSpeechThreshold } public static let `default` = AudioPipelineConfiguration() } -/// Retroactive Sendable conformance for LiveKit types. -/// -/// These types from the LiveKit SDK are marked as @unchecked Sendable because: -/// - MicrophoneMuteMode: A simple enum with no mutable state, inherently thread-safe -/// - SpeechActivityEvent: A value type from LiveKit that is immutable once created -/// -/// Note: These conformances should be reviewed when LiveKit SDK updates to Swift 6 -/// with complete Sendable annotations. -extension MicrophoneMuteMode: @retroactive @unchecked Sendable {} -extension SpeechActivityEvent: @retroactive @unchecked Sendable {} +/// Strategy used when muting the local microphone. Exactly one strategy is active +/// at a time. +public enum MicrophoneMuteMode: Sendable, Equatable { + /// Mutes instantly by silencing the audio engine's input mixer. The mic stays + /// open and the audio session is untouched, so the iOS privacy indicator stays + /// on and no sound effect plays. Fastest option; the recommended default. + /// + /// Note: because the mixer output is silenced *before* the system speech + /// detector, `ConversationClient.isSpeakingWhileMuted` does not fire in this + /// mode โ€” use ``software(speechThreshold:)`` if you need silent muting *and* + /// that detection. + case inputMixer + + /// Mutes by deactivating the audio session and restarting the engine without + /// mic input. The only mode that fully releases the mic โ€” the iOS privacy + /// indicator turns off and other apps can reclaim audio โ€” but mute/unmute is + /// slower and speaking-while-muted detection is unavailable. + case restart + + /// Mutes the voice-processing input. Fast, and the system reports + /// speaking-while-muted via `ConversationClient.isSpeakingWhileMuted`, but iOS + /// plays a short sound effect on mute. The audio session is left active. + case voiceProcessing + + /// Mutes in software: the capture track stays open but all captured audio is + /// zeroed before it leaves the device. Silent (no sound effect) and reports + /// speaking-while-muted via `ConversationClient.isSpeakingWhileMuted` using its + /// own detector โ€” the best of ``inputMixer`` and ``voiceProcessing``. + /// + /// - Parameter speechThreshold: dB level above which muted speech is detected. + /// Default-style value is `-35`; raise to require louder speech, lower for + /// more sensitivity. + case software(speechThreshold: Float) +} diff --git a/Tests/ElevenLabsTests/Unit/SoftwareMuteProcessorTests.swift b/Tests/ElevenLabsTests/Unit/SoftwareMuteProcessorTests.swift index 929a551d..20eb083a 100644 --- a/Tests/ElevenLabsTests/Unit/SoftwareMuteProcessorTests.swift +++ b/Tests/ElevenLabsTests/Unit/SoftwareMuteProcessorTests.swift @@ -13,10 +13,9 @@ final class SoftwareMuteProcessorTests: XCTestCase { expectation.isInverted = true let processor = SoftwareMuteProcessor( - onMutedSpeech: { _ in + onSpeakingWhileMutedChange: { _ in expectation.fulfill() - }, - mutedSpeechThrottleInSeconds: 0 + } ) try processor.audioProcessingProcess(audioBuffer: loadBuffer(named: "spoken-audio")) @@ -28,10 +27,9 @@ final class SoftwareMuteProcessorTests: XCTestCase { expectation.isInverted = true let processor = SoftwareMuteProcessor( - onMutedSpeech: { _ in + onSpeakingWhileMutedChange: { _ in expectation.fulfill() - }, - mutedSpeechThrottleInSeconds: 0 + } ) processor.setMuted(true) @@ -40,13 +38,13 @@ final class SoftwareMuteProcessorTests: XCTestCase { } func testDetectsSpokenTextWhenMuted() throws { - let expectation = expectation(description: "should fire") + let expectation = expectation(description: "should fire started") let processor = SoftwareMuteProcessor( - onMutedSpeech: { _ in + onSpeakingWhileMutedChange: { speaking in + XCTAssertTrue(speaking) expectation.fulfill() - }, - mutedSpeechThrottleInSeconds: 0 + } ) processor.setMuted(true) @@ -56,15 +54,51 @@ final class SoftwareMuteProcessorTests: XCTestCase { wait(for: [expectation], timeout: 2.0) } + func testEmitsEndedWhenSpeechStops() throws { + let started = expectation(description: "should fire started") + let ended = expectation(description: "should fire ended") + + let processor = SoftwareMuteProcessor( + onSpeakingWhileMutedChange: { speaking in + if speaking { started.fulfill() } else { ended.fulfill() } + } + ) + + processor.setMuted(true) + for _ in 0 ..< 4 { + try processor.audioProcessingProcess(audioBuffer: loadBuffer(named: "spoken-audio")) + } + for _ in 0 ..< 3 { + try processor.audioProcessingProcess(audioBuffer: loadBuffer(named: "silence")) + } + wait(for: [started, ended], timeout: 2.0) + } + + func testEmitsEndedWhenUnmutedWhileSpeaking() throws { + let ended = expectation(description: "should fire ended on unmute") + + let processor = SoftwareMuteProcessor( + onSpeakingWhileMutedChange: { speaking in + if !speaking { ended.fulfill() } + } + ) + + processor.setMuted(true) + for _ in 0 ..< 4 { + try processor.audioProcessingProcess(audioBuffer: loadBuffer(named: "spoken-audio")) + } + processor.setMuted(false) + wait(for: [ended], timeout: 2.0) + } + func testSingleLoudBufferDoesNotFireWithDefaultHangover() throws { let expectation = expectation(description: "should not fire on single buffer") expectation.isInverted = true let processor = SoftwareMuteProcessor( - onMutedSpeech: { _ in + onSpeakingWhileMutedChange: { _ in expectation.fulfill() - }, - mutedSpeechThrottleInSeconds: 0 + } ) processor.setMuted(true) @@ -77,10 +111,9 @@ final class SoftwareMuteProcessorTests: XCTestCase { expectation.isInverted = true let processor = SoftwareMuteProcessor( - onMutedSpeech: { _ in + onSpeakingWhileMutedChange: { _ in expectation.fulfill() - }, - mutedSpeechThrottleInSeconds: 0 + } ) processor.setMuted(true) @@ -92,8 +125,7 @@ final class SoftwareMuteProcessorTests: XCTestCase { func testDoesNotChangeBufferedDataIfUnmuted() throws { let processor = SoftwareMuteProcessor( - onMutedSpeech: { _ in }, - mutedSpeechThrottleInSeconds: 0 + onSpeakingWhileMutedChange: { _ in } ) let buffer = try loadBuffer(named: "spoken-audio") @@ -113,8 +145,7 @@ final class SoftwareMuteProcessorTests: XCTestCase { func testZerosBufferedDataIfMuted() throws { let processor = SoftwareMuteProcessor( - onMutedSpeech: { _ in }, - mutedSpeechThrottleInSeconds: 0 + onSpeakingWhileMutedChange: { _ in } ) let buffer = try loadBuffer(named: "spoken-audio") From df6cf4585b1b6fc8da06594734121232841e464e Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:30:57 +0100 Subject: [PATCH 7/8] feat(events): streaming/partial message reconciliation, textOnly serialization - Message.isPartial (true while an agent message is streaming in from agent_chat_response_part chunks, or a user transcript is tentative; false once finalized or for locally-appended messages) -- lands here, not with the rest of the event-model changes, because adding the field while this file's message-store logic was still unmigrated was verified to cause runtime data corruption in unrelated codepaths (a sendToolResult call started publishing garbage that looked like an unrelated feedback event) -- a partial-migration hazard specific to that combination, not present in either the fully-old or fully-new state. - Conversation+Events.swift: insertUserTranscript/upsertAgentMessage replaced by applyUserTranscript/applyTentativeUserTranscript/ applyAgentResponse/applyAgentResponsePart, backed by shared messageIndex/isNewerThanHighestEventId/appendMessage helpers -- guards against reopening a finalized message or applying a stale/ out-of-order part, which the old version didn't have. Ping replies now fire via a detached Task instead of being awaited inline, so a slow publish doesn't stall the serialized event-handling queue. callbacks.onPing added. Two behavior changes worth explicit reviewer sign-off, not mentioned in the v4 import's stated deviations: the .agentToolResponse handler for toolName == "end_call" no longer calls endConversation() (grep confirms nothing else does either -- server-driven auto-hangup appears gone), and onCanSendFeedbackChange/lastFeedbackSubmittedEventId tracking is removed entirely with no replacement. - EventSerializer/OutgoingEvents: config.textOnly (flattened from conversationOverrides.textOnly) and DynamicVariableValue-based dynamic_variables serialization (was String-only) now that ConversationConfig carries them. ClientToolResultEvent's Any-based init is no longer deprecated (the Encodable overload it pointed reviewers to is gone -- see ConversationClient in the next commit) and throws ConversationError.invalidToolResult instead of silently stringifying an un-encodable result. --- .../Conversation/AgentStateManager.swift | 6 +- .../Conversation/Conversation+Events.swift | 220 +++++++---- .../Internal/Utilities/EventSerializer.swift | 28 +- .../Public/Conversation/Models/Message.swift | 22 ++ .../ElevenLabs/Events/OutgoingEvents.swift | 16 +- .../Unit/ConversationEventHandlerTests.swift | 366 +++++++++++++++++- .../Unit/EventSerializerTests.swift | 137 ++++++- 7 files changed, 663 insertions(+), 132 deletions(-) diff --git a/Sources/ElevenLabs/Internal/Conversation/AgentStateManager.swift b/Sources/ElevenLabs/Internal/Conversation/AgentStateManager.swift index 5b3595c0..14a1ddc5 100644 --- a/Sources/ElevenLabs/Internal/Conversation/AgentStateManager.swift +++ b/Sources/ElevenLabs/Internal/Conversation/AgentStateManager.swift @@ -15,8 +15,8 @@ private typealias TimerTask = Task @MainActor final class AgentStateManager { - private(set) var currentState: ElevenLabs.AgentState = .listening - var onStateChange: ((ElevenLabs.AgentState) -> Void)? + private(set) var currentState: AgentState = .listening + var onStateChange: ((AgentState) -> Void)? private let configuration: AgentStateConfiguration @@ -90,7 +90,7 @@ final class AgentStateManager { } } - private func transitionTo(_ newState: ElevenLabs.AgentState) { + private func transitionTo(_ newState: AgentState) { guard newState != currentState else { return } currentState = newState onStateChange?(newState) diff --git a/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift b/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift index 9b4f4335..e50513d1 100644 --- a/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift +++ b/Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift @@ -8,87 +8,90 @@ extension Conversation { func handleIncomingEvent(_ event: IncomingEvent) async { switch event { case let .userTranscript(e): - insertUserTranscript(content: e.transcript, eventId: e.eventId) + applyUserTranscript(content: e.transcript, eventId: e.eventId) + callbacks.onUserTranscript?(e.transcript, e.eventId) agentStateManager?.processSignal(.userTranscript) - options.onUserTranscript?(e.transcript, e.eventId) + + case let .tentativeUserTranscript(e): + applyTentativeUserTranscript(content: e.transcript, eventId: e.eventId) + callbacks.onTentativeUserTranscript?(e.transcript, e.eventId) case let .agentResponse(e): - upsertAgentMessage(content: e.response, eventId: e.eventId) - lastAgentEventId = e.eventId + applyAgentResponse(content: e.response, eventId: e.eventId) + callbacks.onAgentResponse?(e.response, e.eventId) agentStateManager?.processSignal(.agentResponse) - options.onAgentResponse?(e.response, e.eventId) - if lastFeedbackSubmittedEventId.map({ e.eventId > $0 }) ?? true { - options.onCanSendFeedbackChange?(true) - } case let .agentResponseCorrection(correction): - upsertAgentMessage(content: correction.correctedAgentResponse, eventId: correction.eventId) - options.onAgentResponseCorrection?( + applyAgentResponse( + content: correction.correctedAgentResponse, + eventId: correction.eventId + ) + callbacks.onAgentResponseCorrection?( correction.originalAgentResponse, correction.correctedAgentResponse, correction.eventId ) + case let .agentChatResponsePart(e): + applyAgentResponsePart(text: e.text, type: e.type, eventId: e.eventId) + callbacks.onAgentResponsePart?(e.text, e.type, e.eventId) + case let .agentResponseMetadata(metadata): - options.onAgentResponseMetadata?( + callbacks.onAgentResponseMetadata?( metadata.metadataData, metadata.eventId ) - case let .agentChatResponsePart(e): - let existing = messages.last(where: { $0.role == .agent && $0.eventId == e.eventId })?.content ?? "" - upsertAgentMessage(content: existing + e.text, eventId: e.eventId) - case let .audio(audioEvent): - latestAudioEvent = audioEvent - latestAudioAlignment = audioEvent.alignment if let alignment = audioEvent.alignment { - options.onAudioAlignment?(alignment) + callbacks.onAudioAlignment?(alignment) } case let .interruption(interruptionEvent): speakingTimer?.cancel() - applyStateSignal(.interruption, fallback: .listening) - options.onInterruption?(interruptionEvent.eventId) - options.onCanSendFeedbackChange?(false) + isAgentSpeaking = false + feedAgentState(.interruption, fallback: .listening) + callbacks.onInterruption?(interruptionEvent.eventId) case let .conversationMetadata(metadata): - // Store the conversation metadata for public access conversationMetadata = metadata - options.onConversationMetadata?(metadata) + // This event completes the startup handshake: release any waiter + // blocking `connect()` on metadata receipt. + resumeConversationMetadataWaiter() case let .ping(p): - // Respond to ping with pong - let pong = OutgoingEvent.pong(PongEvent(eventId: p.eventId)) - try? await publish(pong) - - case let .clientToolCall(toolCall): - // Add to pending tool calls for the app to handle - options.onUnhandledClientToolCall?(toolCall) - pendingToolCalls.append(toolCall) + if let pingMs = p.pingMs { + callbacks.onPing?(pingMs) + } + // Send pong off the serialized handler loop: awaiting the publish + // here would let a slow transport stall delivery of every queued + // event behind this heartbeat. Pong is keyed by `eventId`, so + // out-of-order delivery is fine. + let eventId = p.eventId + Task { @MainActor [weak self] in + try? await self?.publish(.pong(PongEvent(eventId: eventId))) + } case let .vadScore(vad): + callbacks.onVadScore?(vad.vadScore) agentStateManager?.processSignal(.vadScore(vad.vadScore)) - options.onVadScore?(vad.vadScore) - - case let .agentToolResponse(toolResponse): - applyStateSignal(.agentToolResponse, fallback: .listening) - if toolResponse.toolName == "end_call" { - await endConversation() - } - options.onAgentToolResponse?(toolResponse) + case let .clientToolCall(toolCall): + // Append before invoking the callback so a handler that inspects + // `pendingToolCalls` (directly or via the mirrored client property) + // already sees the new call. + pendingToolCalls.append(toolCall) + callbacks.onClientToolCall?(toolCall) case let .agentToolRequest(toolRequest): - applyStateSignal(.agentToolRequest, fallback: .thinking) - options.onAgentToolRequest?(toolRequest) + feedAgentState(.agentToolRequest, fallback: .thinking) + callbacks.onAgentToolRequest?(toolRequest) - case .tentativeUserTranscript: - // Tentative user transcript (in-progress transcription) - break + case let .agentToolResponse(toolResponse): + feedAgentState(.agentToolResponse, fallback: .listening) + callbacks.onAgentToolResponse?(toolResponse) case let .mcpToolCall(toolCall): - // Update or append MCP tool call based on toolCallId if let index = mcpToolCalls.firstIndex(where: { $0.toolCallId == toolCall.toolCallId }) { mcpToolCalls[index] = toolCall } else { @@ -96,44 +99,117 @@ extension Conversation { } case let .mcpConnectionStatus(status): - // Update MCP connection status mcpConnectionStatus = status case let .error(errorEvent): - logger.error("Received error event from server: code=\(errorEvent.code), message=\(errorEvent.message ?? "none")") - options.onError?(.serverError(errorEvent)) + logger.error("Received error event from server: code=\(errorEvent.code), name=\(errorEvent.errorName ?? "none"), message=\(errorEvent.message ?? "none")") + callbacks.onError?(.serverError(errorEvent)) } } - /// Inserts the user transcript before the agent message with the same `eventId` - /// if one exists, since the agent's response may be received before the transcript. - private func insertUserTranscript(content: String, eventId: Int) { - let message = Message( - id: UUID().uuidString, - role: .user, - content: content, - timestamp: Date(), - eventId: eventId - ) - if let agentIdx = messages.firstIndex(where: { $0.role == .agent && $0.eventId == eventId }) { - messages.insert(message, at: agentIdx) + // MARK: - Transcript / response reconciliation + // + // `messages` is keyed by role + event id and only ever appended, never + // reordered, so order follows the arrival of finalized text: + // * Finalized text (`agent_response`, `agent_response_correction`, + // `user_transcript`) is always recorded โ€” a matching event id updates in + // place, otherwise it's appended (even out of order). + // * Streaming parts (`agent_chat_response_part`, `tentative_user_transcript`) + // only open a new partial when their event id is newer than the role's + // highest; otherwise they're stale and ignored. + // Event ids stay unique per role; partial user transcripts are cleared on + // every tentative/final user transcript. + + /// `agent_chat_response_part`: accumulates streamed text. A finalized message + /// (`.stop` already seen) is never reopened, and a stale part (older than the + /// agent's highest event id) never opens a new bubble. + private func applyAgentResponsePart(text: String, type: AgentChatResponsePartType, eventId: Int) { + let isPartial = type != .stop + guard let idx = messageIndex(role: .agent, eventId: eventId) else { + if isNewerThanHighestEventId(role: .agent, eventId: eventId) { + appendMessage(role: .agent, content: text, eventId: eventId, isPartial: isPartial) + } + return + } + guard messages[idx].isPartial else { return } + messages[idx] = messages[idx].updating(content: messages[idx].content + text, eventId: eventId, isPartial: isPartial) + } + + /// `agent_response` | `agent_response_correction`: the finalized response for a + /// turn. Replaces the matching message in place, or records it (appending, + /// even out of order) when no slot exists yet. + private func applyAgentResponse(content: String, eventId: Int) { + if let idx = messageIndex(role: .agent, eventId: eventId) { + messages[idx] = messages[idx].updating(content: content, eventId: eventId, isPartial: false) } else { - messages.append(message) + appendMessage(role: .agent, content: content, eventId: eventId, isPartial: false) } } - private func upsertAgentMessage(content: String, eventId: Int) { - if let idx = messages.lastIndex(where: { $0.role == .agent && $0.eventId == eventId }) { - let existing = messages[idx] - messages[idx] = Message( - id: existing.id, - role: .agent, - content: content, - timestamp: existing.timestamp, - eventId: eventId - ) + /// `user_transcript`: the finalized user transcript. Finalizes the matching + /// in-progress partial, or records it (appending, even out of order) when no + /// slot exists; then drops any leftover partial (a tentative that never + /// produced its own final). + private func applyUserTranscript(content: String, eventId: Int) { + if let idx = messageIndex(role: .user, eventId: eventId) { + messages[idx] = messages[idx].updating(content: content, eventId: eventId, isPartial: false) } else { - appendMessage(role: .agent, content: content, eventId: eventId) + appendMessage(role: .user, content: content, eventId: eventId, isPartial: false) } + // A finalized transcript ends the turn, so any leftover in-progress + // partial is stale and removed. + messages.removeAll { $0.role == .user && $0.isPartial } + } + + /// `tentative_user_transcript`: the in-progress user transcript. Supersedes any + /// existing partial, then surfaces a fresh one if it belongs to a turn newer + /// than the user's highest event id. + private func applyTentativeUserTranscript(content: String, eventId: Int) { + messages.removeAll { $0.role == .user && $0.isPartial } + guard isNewerThanHighestEventId(role: .user, eventId: eventId) else { return } + appendMessage(role: .user, content: content, eventId: eventId, isPartial: true) + } + + /// Index of the `role` message with exactly `eventId`, scanning tail-first so + /// the common "touch the latest message" case is cheap. Event ids are unique + /// per role (matches update in place), so first/last match the same element. + private func messageIndex(role: Message.Role, eventId: Int) -> Int? { + messages.lastIndex { $0.role == role && $0.eventId == eventId } + } + + /// Whether `eventId` is greater than the highest event id recorded for `role`. + /// Uses the max rather than the last message, because finalized responses can + /// append out of order and locally-sent messages carry no event id (skipped). + private func isNewerThanHighestEventId(role: Message.Role, eventId: Int) -> Bool { + guard let highest = messages.compactMap({ $0.role == role ? $0.eventId : nil }).max() else { return true } + return eventId > highest + } + + private func appendMessage(role: Message.Role, content: String, eventId: Int, isPartial: Bool) { + messages.append( + Message( + id: UUID().uuidString, + role: role, + content: content, + timestamp: Date(), + eventId: eventId, + isPartial: isPartial + ) + ) + } +} + +private extension Message { + /// A copy with new `content`/`eventId`/`isPartial`, preserving the stable + /// `id`, `role`, and `timestamp` so SwiftUI identity and ordering hold. + func updating(content: String, eventId: Int?, isPartial: Bool) -> Message { + Message( + id: id, + role: role, + content: content, + timestamp: timestamp, + eventId: eventId, + isPartial: isPartial + ) } } diff --git a/Sources/ElevenLabs/Internal/Utilities/EventSerializer.swift b/Sources/ElevenLabs/Internal/Utilities/EventSerializer.swift index 141c769e..4952b9c1 100644 --- a/Sources/ElevenLabs/Internal/Utilities/EventSerializer.swift +++ b/Sources/ElevenLabs/Internal/Utilities/EventSerializer.swift @@ -10,9 +10,6 @@ enum EventSerializer { json["type"] = "pong" json["event_id"] = pongEvent.eventId - case let .userAudio(audioEvent): - json["user_audio_chunk"] = audioEvent.audioChunk - case let .conversationInit(initEvent): json["type"] = "conversation_initiation_client_data" if let config = initEvent.config { @@ -29,7 +26,9 @@ enum EventSerializer { json["tool_call_id"] = resultEvent.toolCallId json["result"] = resultEvent.result json["is_error"] = resultEvent.isError - json["error_type"] = resultEvent.errorType?.rawValue + if let errorType = resultEvent.errorType { + json["error_type"] = errorType.rawValue + } case let .contextualUpdate(updateEvent): json["type"] = "contextual_update" @@ -93,32 +92,19 @@ enum EventSerializer { } // Conversation overrides - if let conversationOverrides = config.conversationOverrides { - var conversation: [String: Any] = [:] - if conversationOverrides.textOnly { - conversation["text_only"] = true - } - if let clientEvents = conversationOverrides.clientEvents { - conversation["client_events"] = clientEvents - } - if !conversation.isEmpty { - configOverride["conversation"] = conversation - } + if config.textOnly { + configOverride["conversation"] = ["text_only": true] } if !configOverride.isEmpty { json["conversation_config_override"] = configOverride } - if let customBody = config.customLlmExtraBody { - json["custom_llm_extra_body"] = customBody - } - if let dynamicVars = config.dynamicVariables { - json["dynamic_variables"] = dynamicVars + json["dynamic_variables"] = dynamicVars.mapValues(\.jsonObject) } - // Add source_info (equivalent to client in React Native) + // Identify the SDK to the orchestrator. var sourceInfo: [String: Any] = [:] sourceInfo["source"] = "swift_sdk" sourceInfo["version"] = SDKVersion.version diff --git a/Sources/ElevenLabs/Public/Conversation/Models/Message.swift b/Sources/ElevenLabs/Public/Conversation/Models/Message.swift index 16292651..8020fcf0 100644 --- a/Sources/ElevenLabs/Public/Conversation/Models/Message.swift +++ b/Sources/ElevenLabs/Public/Conversation/Models/Message.swift @@ -7,9 +7,31 @@ public struct Message: Identifiable, Sendable { public let timestamp: Date /// Server-assigned event id used for per-message operations like `sendFeedback`; `nil` for locally appended messages. public let eventId: Int? + /// Whether the message is still being assembled and may change. `true` while + /// an agent message is streaming in from `agent_chat_response_part` chunks, + /// or while a user message reflects an in-progress (tentative) transcript. + /// It flips to `false` once the finalized agent response or user transcript + /// arrives. Locally appended messages are always final (`false`). + public let isPartial: Bool public enum Role: Sendable { case user case agent } + + init( + id: String, + role: Role, + content: String, + timestamp: Date, + eventId: Int?, + isPartial: Bool = false + ) { + self.id = id + self.role = role + self.content = content + self.timestamp = timestamp + self.eventId = eventId + self.isPartial = isPartial + } } diff --git a/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift b/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift index def9de05..c37b55de 100644 --- a/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift +++ b/Sources/ElevenLabs/Public/ElevenLabs/Events/OutgoingEvents.swift @@ -3,9 +3,8 @@ import Foundation // MARK: - Outgoing Events (to ElevenLabs) /// Events that can be sent to the ElevenLabs agent -public enum OutgoingEvent { +public enum OutgoingEvent: Sendable { case pong(PongEvent) - case userAudio(UserAudioEvent) case conversationInit(ConversationInitEvent) case feedback(FeedbackEvent) case clientToolResult(ClientToolResultEvent) @@ -24,15 +23,6 @@ public struct PongEvent: Sendable { } } -/// User audio chunk -public struct UserAudioEvent: Sendable { - public let audioChunk: String // base64 encoded - - public init(audioChunk: String) { - self.audioChunk = audioChunk - } -} - /// Conversation initialization public struct ConversationInitEvent: Sendable { public let config: ConversationConfig? @@ -58,17 +48,15 @@ public struct FeedbackEvent: Sendable { } } -/// Categorizes a client tool failure for the orchestrator. +/// Client tool execution result public enum ClientToolErrorType: String, Sendable { case userRejected = "user_rejected" case externalServer = "external_server" case externalClient = "external_client" case customerAuth = "customer_auth" - case clientTimeout = "client_timeout" case unknown } -/// Client tool execution result public struct ClientToolResultEvent: Sendable { public let toolCallId: String public let result: String diff --git a/Tests/ElevenLabsTests/Unit/ConversationEventHandlerTests.swift b/Tests/ElevenLabsTests/Unit/ConversationEventHandlerTests.swift index 906e9cf3..1d365d3f 100644 --- a/Tests/ElevenLabsTests/Unit/ConversationEventHandlerTests.swift +++ b/Tests/ElevenLabsTests/Unit/ConversationEventHandlerTests.swift @@ -29,7 +29,7 @@ final class ConversationEventHandlerTests: XCTestCase { conversation = Conversation( dependencyProvider: mockDependencyProvider, - options: ConversationOptions( + callbacks: ConversationCallbacks( onUserTranscript: { transcript, eventId in Task { await receivedTranscripts.append((transcript, eventId)) } expectation.fulfill() @@ -59,7 +59,7 @@ final class ConversationEventHandlerTests: XCTestCase { conversation = Conversation( dependencyProvider: mockDependencyProvider, - options: ConversationOptions( + callbacks: ConversationCallbacks( onAgentResponse: { response, eventId in XCTAssertEqual(response, "I am an AI") XCTAssertEqual(eventId, 456) @@ -79,7 +79,6 @@ final class ConversationEventHandlerTests: XCTestCase { XCTAssertEqual(conversation.messages.last?.content, "I am an AI") XCTAssertEqual(conversation.messages.last?.role, .agent) XCTAssertEqual(conversation.messages.last?.eventId, 456) - XCTAssertEqual(conversation.lastAgentEventId, 456) } func testAgentResponseFinalizesStreamedMessageInsteadOfDuplicating() async { @@ -109,6 +108,167 @@ final class ConversationEventHandlerTests: XCTestCase { XCTAssertEqual(conversation.messages.last?.eventId, 42) } + func testAgentChatResponsePartMarksMessagePartialUntilStop() async { + // The callback fires synchronously within handleIncomingEvent, so record + // synchronously to preserve order (a detached Task would reorder). + let deltas = OrderedRecorder<(String, AgentChatResponsePartType, Int)>() + conversation = Conversation( + dependencyProvider: mockDependencyProvider, + callbacks: ConversationCallbacks( + onAgentResponsePart: { text, type, eventId in + deltas.append((text, type, eventId)) + } + ) + ) + + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: "", type: .start, eventId: 7) + )) + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: "Hel", type: .delta, eventId: 7) + )) + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: "lo", type: .delta, eventId: 7) + )) + + XCTAssertEqual(conversation.messages.count, 1) + XCTAssertEqual(conversation.messages.last?.content, "Hello") + XCTAssertEqual(conversation.messages.last?.isPartial, true) + + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: "", type: .stop, eventId: 7) + )) + + XCTAssertEqual(conversation.messages.count, 1) + XCTAssertEqual(conversation.messages.last?.content, "Hello") + XCTAssertEqual(conversation.messages.last?.isPartial, false, ".stop should finalize the streamed message") + + let receivedDeltas = deltas.values + XCTAssertEqual(receivedDeltas.map(\.0), ["", "Hel", "lo", ""]) + XCTAssertEqual(receivedDeltas.map(\.1), [.start, .delta, .delta, .stop]) + XCTAssertEqual(receivedDeltas.map(\.2), [7, 7, 7, 7]) + } + + func testTentativeUserTranscriptCreatesPartialMessageFinalizedByTranscript() async { + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "hel", eventId: 5) + )) + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "hello the", eventId: 5) + )) + + XCTAssertEqual(conversation.messages.count, 1) + XCTAssertEqual(conversation.messages.last?.role, .user) + XCTAssertEqual(conversation.messages.last?.content, "hello the") + XCTAssertEqual(conversation.messages.last?.isPartial, true) + + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "hello there", eventId: 5) + )) + + XCTAssertEqual(conversation.messages.count, 1, "Final transcript should finalize the partial in place") + XCTAssertEqual(conversation.messages.last?.content, "hello there") + XCTAssertEqual(conversation.messages.last?.isPartial, false) + } + + func testNewTranscriptSupersedesStrayPartialWithDifferentEventId() async { + // Mirrors an observed sequence: a tentative transcript that never + // produces a matching final, followed by a new tentative with a higher + // event id. The stray partial must be superseded in place, not left + // behind as a second user bubble. + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "Set up.", eventId: 245) + )) + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "Set up a good test.", eventId: 249) + )) + + XCTAssertEqual(conversation.messages.count, 1, "A newer tentative must replace the stray partial, not append") + XCTAssertEqual(conversation.messages.last?.content, "Set up a good test.") + XCTAssertEqual(conversation.messages.last?.eventId, 249) + XCTAssertEqual(conversation.messages.last?.isPartial, true) + + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "Set up a good test.", eventId: 249) + )) + + XCTAssertEqual(conversation.messages.count, 1) + XCTAssertEqual(conversation.messages.last?.content, "Set up a good test.") + XCTAssertEqual(conversation.messages.last?.eventId, 249) + XCTAssertEqual(conversation.messages.last?.isPartial, false) + } + + func testOutOfOrderUserTranscriptIsStillRecorded() async { + // A finalized transcript whose event id is older than the latest user + // message and matches nothing is still recorded (appended in arrival + // order): finals are canonical and never dropped. + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "current", eventId: 20) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "stale", eventId: 10) + )) + + XCTAssertEqual(conversation.messages.count, 2, "An unmatched final is recorded, not dropped") + XCTAssertEqual(conversation.messages.map(\.content), ["current", "stale"]) + XCTAssertEqual(conversation.messages.compactMap(\.eventId), [20, 10]) + } + + func testOutOfOrderAgentResponseIsStillRecorded() async { + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "current", eventId: 20) + )) + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "stale", eventId: 10) + )) + + XCTAssertEqual(conversation.messages.count, 2, "An unmatched final is recorded, not dropped") + XCTAssertEqual(conversation.messages.map(\.content), ["current", "stale"]) + XCTAssertEqual(conversation.messages.compactMap(\.eventId), [20, 10]) + } + + func testFinalTranscriptClearsStrayPartialFromEarlierTurn() async { + // A tentative that never finalized, followed directly by a final for a + // newer turn (no preceding tentative): the final appends and the stray + // partial is dropped, leaving a single finalized message. + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "umm", eventId: 10) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "what time is it", eventId: 12) + )) + + XCTAssertEqual(conversation.messages.count, 1, "Stray partial must be dropped when a final is committed") + XCTAssertEqual(conversation.messages.last?.content, "what time is it") + XCTAssertEqual(conversation.messages.last?.eventId, 12) + XCTAssertEqual(conversation.messages.last?.isPartial, false) + } + + func testNextTentativeClearsStrayPartialBeforeFinalizing() async { + // A tentative that never finalized, followed by an agent message, then a + // new turn: the new turn's tentative supersedes the stray partial, and the + // final finalizes it โ€” leaving no orphaned partial bubble. + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "stray", eventId: 10) + )) + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "agent reply", eventId: 11) + )) + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "real ques", eventId: 12) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "real question", eventId: 12) + )) + + XCTAssertEqual(conversation.messages.count, 2, "Stray partial must be superseded, not kept") + XCTAssertEqual(conversation.messages[0].role, .agent) + XCTAssertEqual(conversation.messages[0].content, "agent reply") + XCTAssertEqual(conversation.messages[1].role, .user) + XCTAssertEqual(conversation.messages[1].content, "real question") + XCTAssertEqual(conversation.messages[1].isPartial, false) + } + func testAgentResponseAppendsWhenNoStreamedMessagePending() async { await conversation.handleIncomingEvent(.agentResponse( AgentResponseEvent(response: "First", eventId: 1) @@ -122,6 +282,22 @@ final class ConversationEventHandlerTests: XCTestCase { XCTAssertEqual(conversation.messages[1].eventId, 2) } + func testAgentResponseOverwritesFinalizedMessageForSameEventId() async { + // agent_response is canonical for its turn: a later one with the same + // event id replaces the stored content in place, even after finalization. + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "first final", eventId: 1) + )) + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "revised final", eventId: 1) + )) + + XCTAssertEqual(conversation.messages.count, 1) + XCTAssertEqual(conversation.messages.last?.content, "revised final") + XCTAssertEqual(conversation.messages.last?.eventId, 1) + XCTAssertEqual(conversation.messages.last?.isPartial, false) + } + // MARK: - Agent Response Correction Tests func testAgentResponseCorrectionUpdatesStoredMessage() async { @@ -177,7 +353,26 @@ final class ConversationEventHandlerTests: XCTestCase { XCTAssertEqual(conversation.messages.last?.role, .user) } - func testUserTranscriptInsertedBeforeAgentMessageWithSameEventId() async { + func testUserTranscriptOverwritesFinalizedMessageForSameEventId() async { + // A repeated final transcript for the same event id replaces the stored + // content in place rather than being ignored. + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "first final", eventId: 1) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "revised final", eventId: 1) + )) + + XCTAssertEqual(conversation.messages.count, 1) + XCTAssertEqual(conversation.messages.last?.content, "revised final") + XCTAssertEqual(conversation.messages.last?.eventId, 1) + XCTAssertEqual(conversation.messages.last?.isPartial, false) + } + + func testUserTranscriptAppendedInArrivalOrderEvenWhenSharingAgentEventId() async { + // A user transcript that happens to share an agent message's eventId is + // a distinct message; it is appended in arrival order rather than merged + // into, or reordered ahead of, the agent message. await conversation.handleIncomingEvent(.agentResponse( AgentResponseEvent(response: "agent reply", eventId: 5) )) @@ -186,10 +381,10 @@ final class ConversationEventHandlerTests: XCTestCase { )) XCTAssertEqual(conversation.messages.count, 2) - XCTAssertEqual(conversation.messages[0].role, .user) - XCTAssertEqual(conversation.messages[0].content, "user said this") - XCTAssertEqual(conversation.messages[1].role, .agent) - XCTAssertEqual(conversation.messages[1].content, "agent reply") + XCTAssertEqual(conversation.messages[0].role, .agent) + XCTAssertEqual(conversation.messages[0].content, "agent reply") + XCTAssertEqual(conversation.messages[1].role, .user) + XCTAssertEqual(conversation.messages[1].content, "user said this") } // MARK: - Interruption Tests @@ -199,7 +394,7 @@ final class ConversationEventHandlerTests: XCTestCase { conversation = Conversation( dependencyProvider: mockDependencyProvider, - options: ConversationOptions( + callbacks: ConversationCallbacks( onInterruption: { eventId in XCTAssertEqual(eventId, 789) expectation.fulfill() @@ -212,7 +407,7 @@ final class ConversationEventHandlerTests: XCTestCase { await conversation.handleIncomingEvent(event) await fulfillment(of: [expectation], timeout: 1.0) - XCTAssertEqual(conversation.agentState, .listening) + XCTAssertFalse(conversation.isAgentSpeaking) } // MARK: - Streaming Tests @@ -240,4 +435,155 @@ final class ConversationEventHandlerTests: XCTestCase { XCTAssertEqual(conversation.messages.last?.content, "Hello World!") XCTAssertEqual(conversation.messages.last?.eventId, 13) } + + // MARK: - Message Store Consistency Rules + // + // These exercise the guarantees the store maintains for any event sequence: + // 1. Absolute order follows the arrival of finalized transcripts/responses; + // messages are only appended (never reordered), so an unmatched final is + // recorded even when its event id arrives out of order. + // 2. At most one in-progress (partial) user transcript exists at a time. + // 3. User-message event ids are unique (a matching id updates in place). + // 4. Agent-message event ids are unique (the streamed and finalized message + // for a turn coalesce under one id). + // 5. A partial never overwrites a finalized message; a stale streaming part or + // tentative transcript (older than the role's highest event id) is ignored. + + /// Rule 1: the absolute order of messages follows the arrival of the + /// finalized transcripts/responses; messages are never reordered. + func testFinalOrderFollowsArrivalOfFinalizedMessages() async { + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "u1", eventId: 1) + )) + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "a1", eventId: 2) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "u2", eventId: 3) + )) + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "a2", eventId: 4) + )) + + XCTAssertEqual(conversation.messages.map(\.content), ["u1", "a1", "u2", "a2"]) + XCTAssertEqual(conversation.messages[0].role, .user) + XCTAssertEqual(conversation.messages[1].role, .agent) + XCTAssertEqual(conversation.messages[2].role, .user) + XCTAssertEqual(conversation.messages[3].role, .agent) + } + + /// Rule 2: at most one partial user transcript exists at a time. A new + /// tentative supersedes the previous one, even while an agent message streams + /// concurrently. + func testAtMostOnePartialUserTranscriptWhileAgentStreams() async { + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "u partial", eventId: 1) + )) + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: "a", type: .start, eventId: 2) + )) + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "u partial revised", eventId: 3) + )) + + let partialUsers = conversation.messages.filter { $0.role == .user && $0.isPartial } + XCTAssertEqual(partialUsers.count, 1) + XCTAssertEqual(partialUsers.first?.content, "u partial revised") + XCTAssertEqual(partialUsers.first?.eventId, 3) + // The concurrently-streaming agent partial is untouched. + XCTAssertEqual(conversation.messages.filter { $0.role == .agent && $0.isPartial }.count, 1) + } + + /// Rule 3: user-message event ids are unique; in-order finals stay ordered. + func testUserTranscriptsRemainOrderedAndUniqueByEventId() async { + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "p", eventId: 10) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "first", eventId: 10) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "second", eventId: 20) + )) + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "third", eventId: 30) + )) + + let userIds = conversation.messages.filter { $0.role == .user }.compactMap(\.eventId) + XCTAssertEqual(userIds, [10, 20, 30]) + XCTAssertEqual(userIds, userIds.sorted()) + XCTAssertEqual(Set(userIds).count, userIds.count, "No duplicate user event ids") + } + + /// Rule 4: agent-message event ids are unique, with the streamed/finalized + /// message for a turn coalesced under one id. + func testAgentResponsesRemainOrderedAndUniqueByEventId() async { + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: "h", type: .start, eventId: 1) + )) + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: "i", type: .stop, eventId: 1) + )) + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "hi", eventId: 1) + )) + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "next", eventId: 2) + )) + + let agentIds = conversation.messages.filter { $0.role == .agent }.compactMap(\.eventId) + XCTAssertEqual(agentIds, [1, 2]) + XCTAssertEqual(Set(agentIds).count, agentIds.count, "No duplicate agent event ids") + } + + /// Rule 5: a late partial must not overwrite a finalized agent message. + func testPartialAgentUpdateDoesNotOverwriteFinalizedMessage() async { + await conversation.handleIncomingEvent(.agentResponse( + AgentResponseEvent(response: "final answer", eventId: 1) + )) + XCTAssertEqual(conversation.messages.last?.isPartial, false) + + await conversation.handleIncomingEvent(.agentChatResponsePart( + AgentChatResponsePartEvent(text: " late", type: .delta, eventId: 1) + )) + + XCTAssertEqual(conversation.messages.count, 1) + XCTAssertEqual(conversation.messages.last?.content, "final answer", "Partial must not overwrite finalized content") + XCTAssertEqual(conversation.messages.last?.isPartial, false, "Partial must not downgrade a finalized message") + } + + /// Rule 5 (user side): a new tentative for the next turn appends a fresh + /// partial and must not rewrite an already-finalized user message. + func testTentativeUserTranscriptDoesNotOverwriteFinalizedMessage() async { + await conversation.handleIncomingEvent(.userTranscript( + UserTranscriptEvent(transcript: "committed", eventId: 1) + )) + await conversation.handleIncomingEvent(.tentativeUserTranscript( + TentativeUserTranscriptEvent(transcript: "typing", eventId: 2) + )) + + XCTAssertEqual(conversation.messages.count, 2) + XCTAssertEqual(conversation.messages[0].content, "committed") + XCTAssertEqual(conversation.messages[0].isPartial, false) + XCTAssertEqual(conversation.messages[1].content, "typing") + XCTAssertEqual(conversation.messages[1].isPartial, true) + } +} + +/// Thread-safe, order-preserving recorder for synchronously-invoked callbacks. +private final class OrderedRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + lock.lock() + storage.append(value) + lock.unlock() + } + + var values: [Value] { + lock.lock() + defer { lock.unlock() } + return storage + } } diff --git a/Tests/ElevenLabsTests/Unit/EventSerializerTests.swift b/Tests/ElevenLabsTests/Unit/EventSerializerTests.swift index 16867c92..5a122c8c 100644 --- a/Tests/ElevenLabsTests/Unit/EventSerializerTests.swift +++ b/Tests/ElevenLabsTests/Unit/EventSerializerTests.swift @@ -42,6 +42,79 @@ final class EventSerializerTests: XCTestCase { XCTAssertEqual(json["is_error"] as? Bool, false) } + func testSerializeClientToolResultWithJSON() throws { + // Test with dictionary result that will be converted to JSON string + let dictResult = ["temperature": "25ยฐC", "condition": "Sunny"] + let event = try OutgoingEvent.clientToolResult( + ClientToolResultEvent( + toolCallId: "tool456", + result: dictResult, + isError: false + ) + ) + + let data = try EventSerializer.serializeOutgoingEvent(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(json["type"] as? String, "client_tool_result") + XCTAssertEqual(json["tool_call_id"] as? String, "tool456") + let resultString = json["result"] as? String + XCTAssertNotNil(resultString) + if let resultString { + let parsedResult = try JSONSerialization.jsonObject(with: XCTUnwrap(resultString.data(using: .utf8))) as? [String: String] + XCTAssertEqual(parsedResult?["temperature"], "25ยฐC") + XCTAssertEqual(parsedResult?["condition"], "Sunny") + } + XCTAssertEqual(json["is_error"] as? Bool, false) + } + + func testSerializeClientToolResultWithNumber() throws { + let event = try OutgoingEvent.clientToolResult( + ClientToolResultEvent( + toolCallId: "tool789", + result: 42, + isError: false + ) + ) + + let data = try EventSerializer.serializeOutgoingEvent(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(json["type"] as? String, "client_tool_result") + XCTAssertEqual(json["tool_call_id"] as? String, "tool789") + XCTAssertEqual(json["result"] as? String, "42") + XCTAssertEqual(json["is_error"] as? Bool, false) + } + + func testSerializeClientToolResultWithBool() throws { + let event = try OutgoingEvent.clientToolResult( + ClientToolResultEvent( + toolCallId: "tool-bool", + result: true, + isError: false + ) + ) + + let data = try EventSerializer.serializeOutgoingEvent(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // A boolean must serialize as the JSON literal, not the NSNumber "1". + XCTAssertEqual(json["result"] as? String, "true") + } + + func testClientToolResultRejectsNonSerializableValue() { + struct CustomResult { let value: Int } + + XCTAssertThrowsError( + try ClientToolResultEvent(toolCallId: "tool-bad", result: CustomResult(value: 7)) + ) { error in + // Must fail loudly instead of shipping a `String(describing:)` blob. + guard case .invalidToolResult = error as? ConversationError else { + return XCTFail("Expected ConversationError.invalidToolResult, got \(error)") + } + } + } + func testSerializeClientToolResultWithErrorType() throws { let event = OutgoingEvent.clientToolResult( ClientToolResultEvent( @@ -56,7 +129,6 @@ final class EventSerializerTests: XCTestCase { XCTAssertEqual(json["type"] as? String, "client_tool_result") XCTAssertEqual(json["tool_call_id"] as? String, "tool-rejected") - // errorType implies is_error even when not set explicitly. XCTAssertEqual(json["is_error"] as? Bool, true) XCTAssertEqual(json["error_type"] as? String, "user_rejected") } @@ -83,6 +155,58 @@ final class EventSerializerTests: XCTestCase { XCTAssertEqual(json["type"] as? String, "conversation_initiation_client_data") } + func testSerializeConversationInitWithDynamicVariables() throws { + // TODO(test): Add a generated-schema fixture test once the local AsyncAPI artifact is refreshed. + let config = ConversationConfig( + dynamicVariables: [ + "customer_name": "Ada", + "account_tier": 2, + "is_premium": true, + "tags": ["vip", "beta"], + "nullable": nil + ] + ) + let event = OutgoingEvent.conversationInit( + ConversationInitEvent(config: config) + ) + + let data = try EventSerializer.serializeOutgoingEvent(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let dynamicVariables = try XCTUnwrap(json["dynamic_variables"] as? [String: Any]) + + XCTAssertNil(json["custom_llm_extra_body"]) + XCTAssertEqual(dynamicVariables["customer_name"] as? String, "Ada") + XCTAssertEqual(dynamicVariables["account_tier"] as? Int, 2) + XCTAssertEqual(dynamicVariables["is_premium"] as? Bool, true) + XCTAssertEqual(dynamicVariables["tags"] as? [String], ["vip", "beta"]) + XCTAssertTrue(dynamicVariables["nullable"] is NSNull) + } + + func testSerializeConversationInitTextOnly() throws { + let event = OutgoingEvent.conversationInit( + ConversationInitEvent(config: ConversationConfig(textOnly: true)) + ) + + let data = try EventSerializer.serializeOutgoingEvent(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let configOverride = try XCTUnwrap(json["conversation_config_override"] as? [String: Any]) + let conversation = try XCTUnwrap(configOverride["conversation"] as? [String: Any]) + + XCTAssertEqual(conversation["text_only"] as? Bool, true) + } + + func testSerializeConversationInitOmitsTextOnlyByDefault() throws { + let event = OutgoingEvent.conversationInit( + ConversationInitEvent(config: ConversationConfig()) + ) + + let data = try EventSerializer.serializeOutgoingEvent(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let configOverride = json["conversation_config_override"] as? [String: Any] + + XCTAssertNil(configOverride?["conversation"]) + } + func testSerializeContextualUpdate() throws { let event = OutgoingEvent.contextualUpdate( ContextualUpdateEvent(text: "Updated context") @@ -107,17 +231,6 @@ final class EventSerializerTests: XCTestCase { XCTAssertEqual(json["score"] as? String, "like") XCTAssertEqual(json["event_id"] as? Int, 123) } - - func testSerializeUserAudio() throws { - let event = OutgoingEvent.userAudio( - UserAudioEvent(audioChunk: "base64AudioData") - ) - - let data = try EventSerializer.serializeOutgoingEvent(event) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - XCTAssertEqual(json["user_audio_chunk"] as? String, "base64AudioData") - } } // swiftlint:enable force_cast From c4c78810bb61d5483065554c042db8ce4013f1ce Mon Sep 17 00:00:00 2001 From: Renal Khabibulin <1023593+renal128@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:32:45 +0100 Subject: [PATCH 8/8] feat(client): replace ElevenLabs entry point with ConversationClient - ConversationClient (new, ObservableObject) replaces the static ElevenLabs.startConversation(...) factories and process-wide ElevenLabs.configure(_:)/ElevenLabsConfiguration -- it's a reusable, long-lived object (init(callbacks:), start(auth:config:) async throws, endConversation(), reset()) instead of a one-shot factory returning a Conversation. ElevenLabs.version (public) has no replacement -- SDKVersion.version is internal-only now. - Conversation is no longer public -- it's an internal session type ConversationClient owns per start() call. Its new AsyncStream-serialized event pipeline (eventStreamContinuation/eventConsumerTask) replaces direct closure dispatch from the connection manager. toggleMute/ setMuted/isMuted are renamed toggleMicMute/setMicMuted/isMicMuted, and gain a symmetric toggleAgentMute/setAgentMuted/isAgentMuted; calling the mic-mute methods while disconnected is now a lenient no-op instead of throwing .notConnected. inputTrack/agentAudioTrack (raw LiveKit track access) and audioDevices/selectedAudioDeviceID (device enumeration) are removed with no replacement. sendToolResult's generic Encodable overload is gone -- only the Any-based overload remains, so a plain Encodable struct now throws ConversationError.invalidToolResult instead of JSON-encoding automatically. - CallInfo (agentId-carrying struct) is deleted -- ConversationState no longer exposes the connected agent's id at all. - Removes the dead onRawMessage plumbing that shipped with this v4 import: both connection managers declared it and Conversation.messageSource(for:) existed to feed it, but nothing ever wired connectionManager.onRawMessage to actually fire, so the documented public ConversationCallbacks.onMessage callback could never fire either. Removed both rather than ship a callback that silently never calls back; confirmed via a test (testRawMessageCallbackReceivesSourceAndRawJSON) that failed exactly this way before the fix. - Test updates are almost entirely mechanical renames to match the above (ConversationOptions -> ConversationCallbacks/ConversationConfig split, .active(CallInfo) -> .connected, ElevenLabs.* -> ConversationClient/ ConversationAuth/ElevenLabsEndpoints), plus one real fix: the hardcoded SDK-version string in ElevenLabsSDKTests was still "3.2.0", stale against the intentional 3.2.2 bump already on this branch. ConversationConfigTests/EventSerializerTests/ConversationEventHandlerTests gained real new coverage for the config/event changes earlier in this stack (see their respective commits). Coverage lost with no direct replacement: testSendToolResultEncodesEncodableResult (tracks the Encodable-overload removal above) and testTextOnlyStartDisconnectsPreviousActiveManagerBeforeSwitchingTransports (WebRTC->WebSocket teardown-before-switch ordering). Full suite: 154 tests, 0 failures. --- .../Public/Conversation/Conversation.swift | 801 +++++++++++------- .../Conversation/ConversationOptions.swift | 179 ---- .../Public/Conversation/Models/CallInfo.swift | 5 - .../Public/ConversationClient.swift | 256 ++++++ .../ElevenLabs/ElevenLabs+AgentState.swift | 13 - .../ElevenLabs/ElevenLabs+Configuration.swift | 40 - .../ElevenLabs/ElevenLabs+LogLevel.swift | 16 - .../Public/ElevenLabs/ElevenLabs.swift | 238 ------ .../ElevenLabs/ElevenLabsConfiguration.swift | 71 -- Tests/ElevenLabsTests/ElevenLabsTests.swift | 12 +- .../ConversationIntegrationTests.swift | 42 +- .../Mocks/Conversation+TestStart.swift | 10 + .../StartupPerformanceTest.swift | 23 +- .../Unit/BusinessLogicTests.swift | 126 +-- .../Unit/ConversationTests.swift | 514 +++++++---- .../Unit/ElevenLabsSDKTests.swift | 113 +-- .../Unit/ErrorHandlingIntegrationTests.swift | 254 ++---- .../Unit/LiveKitReadinessDelegateTests.swift | 2 +- 18 files changed, 1325 insertions(+), 1390 deletions(-) delete mode 100644 Sources/ElevenLabs/Public/Conversation/ConversationOptions.swift delete mode 100644 Sources/ElevenLabs/Public/Conversation/Models/CallInfo.swift create mode 100644 Sources/ElevenLabs/Public/ConversationClient.swift delete mode 100644 Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+AgentState.swift delete mode 100644 Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+Configuration.swift delete mode 100644 Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+LogLevel.swift delete mode 100644 Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs.swift delete mode 100644 Sources/ElevenLabs/Public/ElevenLabs/ElevenLabsConfiguration.swift create mode 100644 Tests/ElevenLabsTests/Mocks/Conversation+TestStart.swift diff --git a/Sources/ElevenLabs/Public/Conversation/Conversation.swift b/Sources/ElevenLabs/Public/Conversation/Conversation.swift index cf51b7ec..328dffb8 100644 --- a/Sources/ElevenLabs/Public/Conversation/Conversation.swift +++ b/Sources/ElevenLabs/Public/Conversation/Conversation.swift @@ -4,181 +4,231 @@ import LiveKit // swiftlint:disable file_length type_body_length -/// The central entry point for the ElevenLabs Conversational AI SDK. +/// A single-use conversation session: created `.idle`, connected once, and +/// terminal after it ends. Coordinates the transport (`WebRTCConnectionManager` +/// / `WebSocketConnectionManager`), the protocol parser, and `@Published` UI +/// state. /// -/// **Role:** -/// - Manages the lifecycle of a single conversation session. -/// - Coordinates state between the network layer (`WebRTCConnectionManager`|`WebSocketConnectionManager`), protocol parser -/// (`EventParser`), and the UI (`ObservableObject`). -/// - Handles audio device management and permission checks. -/// -/// **Usage:** -/// Create an instance via `ElevenLabs.startConversation(...)`. Use the `@Published` properties -/// to bind your UI to conversation state. +/// Internal: ``ConversationClient`` owns one per session and mirrors its +/// published state, so the public API only ever sees `ConversationClient`. @MainActor -public final class Conversation: ObservableObject { +final class Conversation: ObservableObject { // MARK: - Public State - @Published public internal(set) var state: ConversationState = .idle - @Published public internal(set) var startupState: ConversationStartupState = .idle - @Published public internal(set) var startupMetrics: ConversationStartupMetrics? - @Published public internal(set) var messages: [Message] = [] - @Published public internal(set) var agentState: ElevenLabs.AgentState = .listening - @Published public internal(set) var isMuted: Bool = true // Start as true, will be updated based on actual state + @Published var state: ConversationState = .idle + @Published var messages: [Message] = [] - /// Stream of client tool calls that need to be executed by the app - @Published public internal(set) var pendingToolCalls: [ClientToolCallEvent] = [] + /// Whether the agent is currently speaking, from the transport's + /// remote-speaking detection. (There is no "listening" state: the agent + /// always listens.) + @Published var isAgentSpeaking: Bool = false - /// Conversation metadata including conversation ID, received when the conversation is initialized - @Published public internal(set) var conversationMetadata: ConversationMetadataEvent? + /// Heuristic tri-state (`.listening`/`.speaking`/`.thinking`). Driven by + /// `AgentStateManager` when `config.agentStateConfiguration` is set; otherwise + /// mirrors `isAgentSpeaking`. + @Published var agentState: AgentState = .listening - /// MCP tool calls from the agent - @Published public internal(set) var mcpToolCalls: [MCPToolCallEvent] = [] + /// Event-based agent-state tracker; non-nil only when configured. + var agentStateManager: AgentStateManager? + /// Whether the local microphone (input) is muted โ€” distinct from agent + /// output muting (``isAgentMuted``). Starts `true`; reconciled with the + /// actual capture state once a voice conversation connects. + @Published var isMicMuted: Bool = true - /// Current MCP connection status for all integrations - @Published public internal(set) var mcpConnectionStatus: MCPConnectionStatusEvent? + /// Whether the user is speaking while the mic is muted. Only detected for + /// ``MicrophoneMuteMode/voiceProcessing`` and + /// ``MicrophoneMuteMode/software(speechThreshold:)``; always `false` + /// otherwise. Resets on unmute and when the conversation ends. + @Published var isSpeakingWhileMuted: Bool = false - /// Latest audio alignment payload emitted by the agent. - @Published public internal(set) var latestAudioAlignment: AudioAlignment? + /// Whether the agent's audio output (playback) is muted. Independent of + /// ``isMicMuted``. Defaults to `false` (agent audible). + @Published var isAgentMuted: Bool = false - /// Latest audio event emitted by the agent. - @Published public internal(set) var latestAudioEvent: AudioEvent? + /// Client tool calls awaiting execution or local completion by the app. + @Published var pendingToolCalls: [ClientToolCallEvent] = [] - /// Device lists (optional to expose; keep `internal` if you don't want them public) - @Published public internal(set) var audioDevices: [AudioDevice] = [] - @Published public internal(set) var selectedAudioDeviceID: String = "" + /// Server metadata (conversation id, audio formats), set once the init + /// handshake is acknowledged. + @Published var conversationMetadata: ConversationMetadataEvent? - var lastAgentEventId: Int? - var lastFeedbackSubmittedEventId: Int? + /// MCP tool calls from the agent. + @Published var mcpToolCalls: [MCPToolCallEvent] = [] - /// Pending mute state to apply after connection completes. - /// Allows setting mute state during connection phase. - private var pendingMuteState: Bool? + /// Current MCP connection status for all integrations. + @Published var mcpConnectionStatus: MCPConnectionStatusEvent? - /// Audio device management - private var audioManager: ConversationAudioManager? + // MARK: - Audio pipeline state + // + // `internal` (not `private`) so the controls in `Conversation+Audio` reach them. - /// Agent state manager for event-based state tracking - var agentStateManager: AgentStateManager? + /// Mute requested mid-connect, applied once the transport is up. + var pendingMuteState: Bool? - /// Forward a signal to the event-based state manager, or fall back to directly setting `agentState`. - func applyStateSignal(_ signal: AgentStateSignal, fallback: ElevenLabs.AgentState) { - if let manager = agentStateManager { - manager.processSignal(signal) - } else { - agentState = fallback - } - } - - func handleRemoteSpeakingUpdate(isSpeaking: Bool) { - if let manager = agentStateManager { - manager.processSignal(isSpeaking ? .agentStartedSpeaking : .agentStoppedSpeaking) - } else if isSpeaking { - speakingTimer?.cancel() - agentState = .speaking - } else { - scheduleBackToListening(delay: 1.0) - } - } + /// Audio device management. + var audioManager: ConversationAudioManager? /// Internal logger, accessible from nonisolated contexts. nonisolated let logger: any Logging - /// Context for logging (e.g. agentId) - private var activeContext: [String: String]? - - /// Audio tracks for advanced use cases - public var inputTrack: LocalAudioTrack? { - activeWebRTCConnectionManager?.inputTrack - } - - public var agentAudioTrack: RemoteAudioTrack? { - activeWebRTCConnectionManager?.agentAudioTrack - } - // MARK: - Init + /// Creates a session. With a `nil` `dependencyProvider`, a production + /// `Dependencies` is built for this conversation; tests inject a provider + /// that vends mock connection managers. `config` is fixed for the session. + /// + /// Audio setup is intentionally deferred to `connect(auth:)`: it activates + /// the capture engine and triggers the mic-permission prompt. init( - dependencyProvider: any ConversationDependencyProvider, - options: ConversationOptions = .default + dependencyProvider: (any ConversationDependencyProvider)? = nil, + config: ConversationConfig = .default, + callbacks: ConversationCallbacks? = nil ) { - self.dependencyProvider = dependencyProvider - self.options = options - logger = dependencyProvider.logger - setupAudioManager() - } - - private func setupAudioManager() { - guard !options.conversationOverrides.textOnly else { return } - let manager = ConversationAudioManager(logger: logger) - manager.onDevicesChanged = { [weak self] devices in - self?.audioDevices = devices - } - manager.onSelectedDeviceChanged = { [weak self] deviceId in - self?.selectedAudioDeviceID = deviceId - } - audioManager = manager - // Sync initial values - audioDevices = manager.audioDevices - selectedAudioDeviceID = manager.selectedAudioDeviceID + // Built in the @MainActor init body (not a default arg) so no + // main-actor-isolated value is referenced from a nonisolated context. + let provider = dependencyProvider ?? Dependencies(logLevel: config.logLevel) + self.provider = provider + self.config = config + self.callbacks = callbacks ?? ConversationCallbacks() + self.logger = provider.logger + setupAgentStateManager() } private func setupAgentStateManager() { - guard let configuration = options.agentStateConfiguration else { return } + guard let configuration = config.agentStateConfiguration else { return } let manager = AgentStateManager(configuration: configuration) - manager.onStateChange = { [weak self] state in - self?.agentState = state - self?.options.onAgentStateChange?(state) - } + manager.onStateChange = { [weak self] state in self?.agentState = state } agentStateManager = manager } - // MARK: - Public API - - /// Start a conversation with an agent using agent ID. - /// - /// Each call to this method creates a fresh Room object, ensuring clean state - /// and preventing any interference from previous conversations. - public func startConversation( - with agentId: String, - options: ConversationOptions = .default - ) async throws { - let authConfig = ElevenLabsConfiguration.publicAgent(id: agentId, environment: options.environment) - try await startConversation(auth: authConfig, options: options) + /// Forward a signal to the event-based manager, or fall back to setting + /// `agentState` directly when no `agentStateConfiguration` was provided. + func feedAgentState(_ signal: AgentStateSignal, fallback: AgentState) { + if let manager = agentStateManager { + manager.processSignal(signal) + } else { + agentState = fallback + } } - /// Start a conversation using authentication configuration. - /// - /// Each call to this method creates a fresh Room object, ensuring clean state - /// and preventing any interference from previous conversations. - public func startConversation( - auth: ElevenLabsConfiguration, - options: ConversationOptions = .default - ) async throws { - guard state == .idle || state.isEnded else { + // MARK: - Lifecycle + + /// Connect this single-use conversation. Throws + /// ``ConversationError/alreadyActive`` unless `.idle` (it connects exactly + /// once). `auth` is consumed here so TTL'd tokens stay fresh. + func connect(auth: ConversationAuth) async throws { + guard state == .idle else { throw ConversationError.alreadyActive } - let result: StartupResult = if options.conversationOverrides.textOnly { - try await startTextOnlyConversation(auth: auth, options: options, provider: dependencyProvider) + let provider = self.provider + + // Each transport drives its own startup phases and returns once the + // init handshake has been sent. + if config.textOnly { + try await startTextOnlyConversation(auth: auth, config: config, provider: provider) } else { - try await startVoiceConversation(auth: auth, options: options, provider: dependencyProvider) + try await startVoiceConversation(auth: auth, config: config, provider: provider) + } + + // Startup may have already ended the session (user ended mid-connect, + // or the agent dropped); don't override that terminal state. + guard state.isConnecting else { + throw CancellationError() } - state = .active(.init(agentId: result.agentId)) - startupMetrics = result.metrics - updateStartupState(.active(CallInfo(agentId: result.agentId), result.metrics)) - options.onAgentReady?() + // Startup completes only when the agent acknowledges the handshake with + // `conversation_initiation_metadata`. Block until it arrives, bounded by + // `conversationInitTimeout`; a timeout surfaces as `.initializationTimeout` + // (distinct from `.agentTimeout`, the voice room-join wait). + state = .connecting(phase: .waitingForInitData) + let metadataReceived = await awaitConversationMetadata( + timeout: config.conversationInitTimeout + ) + + // Teardown while waiting resolves the waiter too; if no longer + // connecting, it already set the terminal state โ€” don't clobber it. + guard state.isConnecting else { + throw CancellationError() + } + // A cooperative cancel (e.g. the caller cancelled the start task) breaks + // the metadata wait; unwind as `CancellationError` rather than a spurious + // init timeout. + if Task.isCancelled { + if let connectionManager = activeConnectionManager { + await handleStartupCancellation(disconnecting: connectionManager) + } + throw CancellationError() + } + guard metadataReceived else { + if let connectionManager = activeConnectionManager { + await handleStartupFailure( + .conversationInit(.initializationTimeout), + disconnecting: connectionManager + ) + } + throw ConversationError.initializationTimeout + } + + state = .connected + callbacks.onAgentReady?() + } + + /// Suspend until `conversation_initiation_metadata` arrives, `timeout` + /// elapses, or the surrounding task is cancelled; returns whether the + /// metadata arrived. Single main-actor waiter resolved exactly once (the + /// continuation nil-check in `resolveMetadataWaiter` enforces this) by the + /// event handler, the timeout task, teardown, or cooperative cancellation. + /// On cancellation it resumes promptly with `false`; `connect` then checks + /// `Task.isCancelled` and unwinds as `CancellationError`. + private func awaitConversationMetadata(timeout: TimeInterval) async -> Bool { + if conversationMetadata != nil { return true } + return await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + metadataContinuation = continuation + // Cancelled before we suspended: resolve now rather than waiting + // out the full timeout. + if Task.isCancelled { + resolveMetadataWaiter(false) + return + } + metadataTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + guard !Task.isCancelled else { return } + self?.resolveMetadataWaiter(false) + } + } + } onCancel: { + Task { @MainActor [weak self] in self?.resolveMetadataWaiter(false) } + } + } + + /// Resolve the in-flight metadata waiter (if any) as succeeded. Called from + /// the event handler when `conversation_initiation_metadata` is received. + func resumeConversationMetadataWaiter() { + resolveMetadataWaiter(true) + } + + /// Resolve the in-flight metadata waiter exactly once and tear down its + /// timeout task. Idempotent via the continuation nil-check, so the event + /// handler, timeout, teardown, and cancellation can all call it racelessly + /// on the main actor. `received` is `true` only when the metadata arrived. + private func resolveMetadataWaiter(_ received: Bool) { + metadataTimeoutTask?.cancel() + metadataTimeoutTask = nil + guard let pending = metadataContinuation else { return } + metadataContinuation = nil + pending.resume(returning: received) } private func startVoiceConversation( - auth: ElevenLabsConfiguration, - options: ConversationOptions, + auth: ConversationAuth, + config: ConversationConfig, provider: any ConversationDependencyProvider - ) async throws -> StartupResult { + ) async throws { let webRTCConnectionManager = provider.webRTCConnectionManager await prepareConversationStart( - auth: auth, options: options, + auth: auth, config: config, connectionManager: webRTCConnectionManager ) @@ -188,98 +238,133 @@ public final class Conversation: ObservableObject { } } + // Build/configure the audio pipeline (pre-warms the capture engine; see + // `ConversationAudioManager.configure`). if audioManager == nil { - setupAudioManager() + audioManager = ConversationAudioManager(logger: logger) + } + await audioManager?.configure(with: config) { [weak self] speaking in + Task { @MainActor in self?.isSpeakingWhileMuted = speaking } } - await audioManager?.configure(with: options) - let result: StartupResult do { - result = try await webRTCConnectionManager.connect( + try await webRTCConnectionManager.connect( auth: auth, - options: options, - onStartupStateChange: { [weak self] newState in - self?.updateStartupState(newState) - } + config: config ) - } catch let failure as StartupFailure { - await handleStartupFailure(failure, disconnecting: webRTCConnectionManager, suggestLocalNetworkPermission: true) - throw failure.error + } catch let failure as ConversationStartupFailure { + let committed = await handleStartupFailure(failure, disconnecting: webRTCConnectionManager) + // If the session was already ended concurrently, the failure was + // suppressed โ€” surface cancellation rather than a stale failure. + throw committed ? failure.error : CancellationError() } catch is CancellationError { await handleStartupCancellation(disconnecting: webRTCConnectionManager) throw CancellationError() } - if let pendingMute = pendingMuteState { - pendingMuteState = nil - do { - try await webRTCConnectionManager.setMicrophoneMuted(pendingMute) - isMuted = pendingMute - } catch { - logger.warning("Failed to apply pending mute state", context: ["error": "\(error)"]) + await reconcileMicMuteAfterConnect(webRTCConnectionManager) + } + + /// Reconcile the published mute flag with the live pipeline once the + /// transport is up, applying any mute requested mid-connect through the + /// *active mute mode*. Software mute keeps the capture track open, so the + /// hardware mic flag is not its source of truth โ€” the software gate is. + private func reconcileMicMuteAfterConnect( + _ webRTCConnectionManager: any WebRTCConnectionManaging + ) async { + let pendingMute = pendingMuteState + pendingMuteState = nil + if let softwareMuteProcessor = audioManager?.softwareMuteProcessor { + if let pendingMute { + softwareMuteProcessor.setMuted(pendingMute) } + isMicMuted = softwareMuteProcessor.muted + } else { + if let pendingMute { + do { + try await webRTCConnectionManager.setMicrophoneMuted(pendingMute) + } catch { + logger.warning("Failed to apply pending mute state", context: ["error": "\(error)"]) + } + } + isMicMuted = webRTCConnectionManager.isMicrophoneMuted } - - isMuted = webRTCConnectionManager.isMicrophoneMuted - return result } private func startTextOnlyConversation( - auth: ElevenLabsConfiguration, - options: ConversationOptions, + auth: ConversationAuth, + config: ConversationConfig, provider: ConversationDependencyProvider - ) async throws -> StartupResult { + ) async throws { let connectionManager = provider.webSocketConnectionManager await prepareConversationStart( - auth: auth, options: options, + auth: auth, config: config, connectionManager: connectionManager ) - updateStartupState(.connectingRoom) - do { - return try await connectionManager.connect(auth: auth, options: options) - } catch let failure as StartupFailure { - await handleStartupFailure(failure, disconnecting: connectionManager, suggestLocalNetworkPermission: false) - throw failure.error + try await connectionManager.connect(auth: auth, config: config) + } catch let failure as ConversationStartupFailure { + let committed = await handleStartupFailure(failure, disconnecting: connectionManager) + // If the session was already ended concurrently, the failure was + // suppressed โ€” surface cancellation rather than a stale failure. + throw committed ? failure.error : CancellationError() } catch is CancellationError { await handleStartupCancellation(disconnecting: connectionManager) throw CancellationError() } } - /// End and clean up. - /// Can be called during connection phase to cancel, or during active conversation to end. - public func endConversation() async { - await endConversation(disconnectReason: .user, endReason: .userEnded) + /// End the conversation (also cancels an in-progress connect). + func endConversation() async { + await endConversation(reason: .userEnded) + } + + /// Clear a terminated session back to `.idle`, discarding the transcript, + /// metadata, and tool/MCP state that ``endConversation()`` deliberately + /// preserves. Lets a UI dismiss a startup failure or a finished transcript + /// without immediately starting a new conversation. + /// + /// No-op unless the session has terminated (`.ended` or `.startupFailed`); + /// end a connecting/connected session first. + func reset() { + switch state { + case .ended, .startupFailed: + break + case .idle, .connecting, .connected: + return + } + + tearDownActiveSession() + messages = [] + conversationMetadata = nil + mcpToolCalls = [] + mcpConnectionStatus = nil + state = .idle } - private func endConversation(disconnectReason: DisconnectionReason = .user, endReason: EndReason = .userEnded) async { - // Allow ending during both active and connecting states - guard state.isActive || state == .connecting else { return } + /// `reason` is the single source of truth for why the session ended; the + /// coarse ``DisconnectionReason`` handed to `onDisconnect` is derived from it. + private func endConversation(reason: EndReason) async { + guard !state.isInactive else { return } guard let connectionManager = activeConnectionManager else { - // No connection manager yet, just reset state - if state == .connecting { + // No transport yet; just reset. + if state.isConnecting { state = .idle tearDownActiveSession() } return } - state = .ended(reason: endReason) + state = .ended(reason: reason) - // Disconnect synchronously to ensure clean state await connectionManager.disconnect() - tearDownActiveSession() - - // Call user's onDisconnect callback if provided - options.onDisconnect?(disconnectReason) - options.onCanSendFeedbackChange?(false) + callbacks.onDisconnect?(DisconnectionReason(reason)) } /// Send a text message to the agent. - public func sendMessage(_ text: String) async throws { - guard state.isActive else { + func sendMessage(_ text: String) async throws { + guard state == .connected else { throw ConversationError.notConnected } let event = OutgoingEvent.userMessage(UserMessageEvent(text: text)) @@ -287,79 +372,36 @@ public final class Conversation: ObservableObject { appendMessage(role: .user, content: text) } - /// Toggle / set microphone - public func toggleMute() async throws { - try await setMuted(!isMuted) - } - - public func setMuted(_ muted: Bool) async throws { - if let softwareMuteProcessor = audioManager?.softwareMuteProcessor { - softwareMuteProcessor.setMuted(muted) - isMuted = muted - return - } - try await setMicrophoneMuted(muted) - } - - /// Mute the microphone. Normally calling setMuted will mute the microphone - /// but if software mute is enabled, the setMuted call will just toggle - /// the software mute. If you still want to explicitly mute the microphone - /// you can use this method. - public func setMicrophoneMuted(_ muted: Bool) async throws { - if state.isActive { - guard let webRTCConnectionManager = activeWebRTCConnectionManager else { - throw ConversationError.notConnected - } - do { - try await webRTCConnectionManager.setMicrophoneMuted(muted) - isMuted = muted - pendingMuteState = nil - } catch WebRTCConnectionManagerError.roomUnavailable { - throw ConversationError.notConnected - } catch { - throw ConversationError.microphoneToggleFailed(error) - } - } else if state == .connecting { - // Buffer the mute state to apply after connection completes - pendingMuteState = muted - isMuted = muted - } else { - throw ConversationError.notConnected - } - } - /// Interrupt the agent while speaking. - public func interruptAgent() async throws { - guard state.isActive else { throw ConversationError.notConnected } + func interruptAgent() async throws { + guard state == .connected else { throw ConversationError.notConnected } let event = OutgoingEvent.userActivity try await publish(event) } - /// Contextual update to agent (system prompt-ish). - public func updateContext(_ context: String) async throws { - guard state.isActive else { throw ConversationError.notConnected } + /// Send a silent contextual update to the agent. + func updateContext(_ context: String) async throws { + guard state == .connected else { throw ConversationError.notConnected } let event = OutgoingEvent.contextualUpdate(ContextualUpdateEvent(text: context)) try await publish(event) } /// Send feedback (like/dislike) for an event/message id. - public func sendFeedback(_ score: FeedbackEvent.Score, eventId: Int) async throws { - guard state.isActive else { + func sendFeedback(_ score: FeedbackEvent.Score, eventId: Int) async throws { + guard state == .connected else { throw ConversationError.notConnected } let event = OutgoingEvent.feedback(FeedbackEvent(score: score, eventId: eventId)) try await publish(event) - lastFeedbackSubmittedEventId = eventId - options.onCanSendFeedbackChange?(false) } /// Approve or reject an MCP tool call request from the agent. /// - Parameters: /// - toolCallId: The tool call identifier from `MCPToolCallEvent`. /// - isApproved: Pass `true` to approve, `false` to reject. - public func sendMCPToolApproval(toolCallId: String, isApproved: Bool) async throws { - guard state.isActive else { throw ConversationError.notConnected } + func sendMCPToolApproval(toolCallId: String, isApproved: Bool) async throws { + guard state == .connected else { throw ConversationError.notConnected } let approval = MCPToolApprovalResultEvent(toolCallId: toolCallId, isApproved: isApproved) try await publish(.mcpToolApprovalResult(approval)) } @@ -395,142 +437,153 @@ public final class Conversation: ObservableObject { } /// Mark a tool call as completed without sending a result (for tools that don't expect responses). - public func markToolCallCompleted(_ toolCallId: String) { + func markToolCallCompleted(_ toolCallId: String) { pendingToolCalls.removeAll { $0.toolCallId == toolCallId } } // MARK: - Private - private let dependencyProvider: any ConversationDependencyProvider + private let provider: any ConversationDependencyProvider private var activeConnectionManager: (any ConnectionManaging)? - private var activeWebRTCConnectionManager: (any WebRTCConnectionManaging)? { + /// `internal` (not `private`) because the audio controls in + /// `Conversation+Audio` reach the WebRTC transport through it. + var activeWebRTCConnectionManager: (any WebRTCConnectionManaging)? { activeConnectionManager as? any WebRTCConnectionManaging } - var options: ConversationOptions + let config: ConversationConfig + let callbacks: ConversationCallbacks var speakingTimer: Task? - private func updateStartupState(_ newState: ConversationStartupState) { - startupState = newState - options.onStartupStateChange?(newState) - } + /// In-flight waiter for `conversation_initiation_metadata` during startup. + private var metadataContinuation: CheckedContinuation? + + /// Times out the metadata wait; cancelled when the waiter resolves for any + /// reason (metadata, teardown, or cooperative cancellation). + private var metadataTimeoutTask: Task? + + /// Serializes incoming events through a single consumer so handlers run in + /// strict arrival order (see `prepareConversationStart`). + private var eventStreamContinuation: AsyncStream.Continuation? + private var eventConsumerTask: Task? /// Common preparation shared by voice and text-only startup paths. private func prepareConversationStart( - auth: ElevenLabsConfiguration, - options: ConversationOptions, + auth: ConversationAuth, + config: ConversationConfig, connectionManager: any ConnectionManaging ) async { - let previousConnectionManager = activeConnectionManager - state = .connecting - - if let previousConnectionManager, previousConnectionManager !== connectionManager { - await previousConnectionManager.disconnect() - } + state = .connecting(phase: .authorizing) activeConnectionManager = connectionManager - // Reset the target manager too; dependency providers may reuse manager instances across starts. + // Reset in case the provider reuses a manager instance (e.g. test + // mocks); a fresh Conversation otherwise starts clean. await connectionManager.disconnect() - cleanupPreviousConversation() - self.options = options - activeContext = ["agentId": auth.agentId] - let mode = options.conversationOverrides.textOnly ? "text-only" : "voice" - logger.info("Starting \(mode) conversation", context: activeContext) - - options.onCanSendFeedbackChange?(false) - setupAgentStateManager() + // Agent id is only known client-side for public-agent / signed-URL auth; + // omit it for conversation-token auth rather than logging a placeholder. + let agentId: String? = switch auth.authSource { + case let .publicAgentId(id): id + case let .signedWebSocketURL(_, id): id + case .conversationToken: nil + } + let mode = config.textOnly ? "text-only" : "voice" + logger.info("Starting \(mode) conversation", context: agentId.map { ["agentId": $0] }) - connectionManager.onEventReceived = { [weak self, weak connectionManager] event in - Task { @MainActor [weak self, weak connectionManager] in - guard let self, - let connectionManager, + connectionManager.onStartupPhaseChange = { [weak self] phase in + self?.state = .connecting(phase: phase) + } + // Serialize incoming events through one consumer so handlers run in + // strict arrival order: yielding is synchronous and ordered, so handlers + // can't interleave across their `await` points (e.g. ping โ†’ pong). + var continuation: AsyncStream.Continuation! + let eventStream = AsyncStream { continuation = $0 } + let streamContinuation: AsyncStream.Continuation = continuation + eventStreamContinuation = streamContinuation + eventConsumerTask = Task { @MainActor [weak self, weak connectionManager] in + for await event in eventStream { + guard let self else { return } + guard let connectionManager, activeConnectionManager === connectionManager, - state == .connecting || state.isActive + !state.isInactive else { - return + continue } - await handleIncomingEvent(event) } } + connectionManager.onEventReceived = { event in + streamContinuation.yield(event) + } + connectionManager.onDisconnected = { [weak self] in guard let self else { return } - await endConversation(disconnectReason: .agent, endReason: .remoteDisconnected) + await endConversation(reason: .remoteDisconnected) } } + /// Move the session into ``ConversationState/startupFailed(_:)`` and fire + /// `onError`. Returns `false` (leaving state untouched, no `onError`) if the + /// session was already torn down concurrently, so the caller can surface + /// cancellation instead of a stale failure. + @discardableResult private func handleStartupFailure( - _ failure: StartupFailure, - disconnecting connectionManager: any ConnectionManaging, - suggestLocalNetworkPermission: Bool - ) async { + _ failure: ConversationStartupFailure, + disconnecting connectionManager: any ConnectionManaging + ) async -> Bool { cleanupTransientResources() await connectionManager.disconnect() - startupMetrics = failure.metrics - state = .idle - updateStartupState(.failed(failure.reason, failure.metrics)) - options.onError?(failure.error) - - if suggestLocalNetworkPermission, - case .room = failure.reason, - LocalNetworkPermissionMonitor.shared.shouldSuggestLocalNetworkPermission() - { - options.onError?(ConversationError.localNetworkPermissionRequired) - } + // Re-check after the `disconnect` suspension: only own the transition + // while still connecting. No `await` between here and the mutation, so + // this is race-free on the main actor. + guard state.isConnecting else { return false } + + state = .startupFailed(failure) + callbacks.onError?(failure.error) + return true } private func handleStartupCancellation(disconnecting connectionManager: any ConnectionManaging) async { cleanupTransientResources() await connectionManager.disconnect() - startupMetrics = nil + // Don't clobber a terminal state set by a concurrent teardown. + guard state.isConnecting else { return } state = .idle - updateStartupState(.idle) - } - - /// Clean up state from any previous conversation to ensure a fresh start. - /// Called when starting a new session; wipes both operational and display state. - private func cleanupPreviousConversation() { - tearDownActiveSession() - - messages.removeAll() - mcpToolCalls.removeAll() - mcpConnectionStatus = nil - conversationMetadata = nil - - startupState = .idle - startupMetrics = nil - - logger.debug("Previous conversation state cleaned up for fresh Room", context: activeContext) } /// Tear down operational state when an active session ends. /// Preserves user-visible display state (messages, MCP activity, conversation - /// metadata, startup metrics) so the transcript remains visible until a new - /// conversation is started. + /// metadata) so the transcript remains visible until a new conversation is + /// started. private func tearDownActiveSession() { cleanupTransientResources() pendingToolCalls.removeAll() - - lastAgentEventId = nil - lastFeedbackSubmittedEventId = nil - options.onCanSendFeedbackChange?(false) - latestAudioEvent = nil - latestAudioAlignment = nil } private func cleanupTransientResources() { speakingTimer?.cancel() speakingTimer = nil pendingMuteState = nil + + // Stop draining incoming events; the session is terminal from here. + eventStreamContinuation?.finish() + eventStreamContinuation = nil + eventConsumerTask?.cancel() + eventConsumerTask = nil + + // Resolve any in-flight startup metadata waiter so its continuation and + // timeout task don't leak if the session is torn down mid-wait. + resolveMetadataWaiter(false) + isAgentSpeaking = false agentState = .listening - isMuted = true + isMicMuted = true + isAgentMuted = false + isSpeakingWhileMuted = false audioManager?.cleanup() - agentStateManager = nil } // MARK: - Testing Hooks @@ -551,14 +604,36 @@ public final class Conversation: ObservableObject { activeConnectionManager = manager } - private func scheduleBackToListening(delay: TimeInterval = 0.5) { + // MARK: - Agent speaking state + + func handleRemoteSpeakingUpdate(isSpeaking: Bool) { + agentStateManager?.processSignal(isSpeaking ? .agentStartedSpeaking : .agentStoppedSpeaking) + if isSpeaking { + speakingTimer?.cancel() + isAgentSpeaking = true + if agentStateManager == nil { agentState = .speaking } + } else { + // Fast attack / slow release: LiveKit's energy-based `isSpeaking` + // toggles off across natural pauses, so hold "speaking" briefly to + // bridge them rather than flickering. The agent resuming within the + // window cancels the pending flip. + scheduleAgentStoppedSpeaking() + } + } + + /// How long to hold `isAgentSpeaking` after LiveKit reports the agent + /// stopped (see `handleRemoteSpeakingUpdate`). + private static let agentStoppedSpeakingDelay: TimeInterval = 0.5 + + private func scheduleAgentStoppedSpeaking() { speakingTimer?.cancel() - speakingTimer = Task { + speakingTimer = Task { [weak self] in do { - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - self.agentState = .listening + try await Task.sleep(nanoseconds: UInt64(Self.agentStoppedSpeakingDelay * 1_000_000_000)) + self?.isAgentSpeaking = false + if self?.agentStateManager == nil { self?.agentState = .listening } } catch { - // Task was cancelled, do nothing + // Cancelled; nothing to do. } } } @@ -586,4 +661,106 @@ public final class Conversation: ObservableObject { } } +/// Audio-facing controls for a `Conversation`: +/// - microphone (input) mute, and +/// - agent (output) mute. +/// +/// Split out of `Conversation` to keep that type focused on connection and +/// protocol logic. The backing stored state (`audioManager`, `pendingMuteState`) +/// lives on `Conversation` as `internal`. +@MainActor +extension Conversation { + // MARK: - Live tracks + // + // Kept internal: consumers control agent playback via `setAgentMuted(_:)`, + // so the LiveKit track types never cross the public boundary. + + private var agentAudioTrack: RemoteAudioTrack? { + activeWebRTCConnectionManager?.agentAudioTrack + } + + // MARK: - Microphone (input) mute + // + // Stops the user's audio from reaching the agent. Distinct from agent + // (output) muting โ€” see `setAgentMuted(_:)` below. + + /// Toggle the local microphone mute state. + func toggleMicMute() async throws { + try await setMicMuted(!isMicMuted) + } + + /// Mute or unmute the local microphone. This is the single mute control. + /// + /// With the `.software` mute mode (see `MicrophoneMuteMode`) this toggles the + /// software gate and keeps the capture track open; for every other mode it + /// hardware-mutes the underlying capture track. When there is no live session + /// to toggle (idle/ended/startup-failed) this is a best-effort no-op rather + /// than an error, mirroring the agent-mute controls. + func setMicMuted(_ muted: Bool) async throws { + if !muted { isSpeakingWhileMuted = false } + if let softwareMuteProcessor = audioManager?.softwareMuteProcessor { + softwareMuteProcessor.setMuted(muted) + isMicMuted = muted + return + } + try await setHardwareMicMuted(muted) + } + + /// Hardware-mute the underlying capture track. Internal implementation detail + /// of ``setMicMuted(_:)`` for the non-software mute modes. + func setHardwareMicMuted(_ muted: Bool) async throws { + if state == .connected { + guard let webRTCConnectionManager = activeWebRTCConnectionManager else { + throw ConversationError.notConnected + } + do { + try await webRTCConnectionManager.setMicrophoneMuted(muted) + isMicMuted = muted + pendingMuteState = nil + } catch ConnectionManagerError.notConnected { + throw ConversationError.notConnected + } catch { + throw ConversationError.microphoneToggleFailed(error) + } + } else if state.isConnecting { + // Buffer the mute state to apply after connection completes + pendingMuteState = muted + isMicMuted = muted + } else { + // Not connected with nothing to apply to (idle/ended/startupFailed): + // best-effort no-op rather than throwing, mirroring the non-throwing + // agent-mute controls. There's no capture track to toggle here. + } + } + + // MARK: - Agent (output) mute + // + // Silences the agent's playback by setting the remote track's gain to zero โ€” + // a pure playback preference, independent of the mic and with no + // hardware/permission coupling. `isAgentMuted` is the source of truth and is + // applied once the agent track arrives. + + /// Toggle whether the agent's audio output (playback) is muted. + func toggleAgentMute() { + setAgentMuted(!isAgentMuted) + } + + /// Mute or unmute the agent's audio output (playback). + /// + /// Sets the remote audio track's gain to `0` (muted) or `1` (unmuted). Safe + /// to call at any time: if the agent track is not available yet, the + /// preference is stored in ``isAgentMuted`` and applied automatically once + /// the track arrives. + func setAgentMuted(_ muted: Bool) { + isAgentMuted = muted + applyAgentMute() + } + + /// Apply the current ``isAgentMuted`` preference to the live agent track. + /// Idempotent and a no-op when no agent track is present. + private func applyAgentMute() { + agentAudioTrack?.volume = isAgentMuted ? 0 : 1 + } +} + // swiftlint:enable file_length type_body_length diff --git a/Sources/ElevenLabs/Public/Conversation/ConversationOptions.swift b/Sources/ElevenLabs/Public/Conversation/ConversationOptions.swift deleted file mode 100644 index 36b6fd29..00000000 --- a/Sources/ElevenLabs/Public/Conversation/ConversationOptions.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation -import LiveKit - -public struct ConversationOptions: Sendable { - /// Determines how microphone setup failures are handled during connection - public enum MicrophoneFailureHandling: Sendable { - /// Throw an error if microphone setup fails (recommended for voice-first apps) - case throwError - /// Log a warning but continue without microphone (useful for fallback scenarios) - case continueWithoutMicrophone - } - - public var conversationOverrides: ConversationOverrides - public var agentOverrides: AgentOverrides? - public var ttsOverrides: TTSOverrides? - public var customLlmExtraBody: [String: String]? // Simplified to be Sendable - public var dynamicVariables: [String: String]? // Simplified to be Sendable - public var userId: String? - /// Optional environment for the agent (defaults to production when nil) - public var environment: String? - - /// How to handle microphone setup failures during connection - public var microphoneFailureHandling: MicrophoneFailureHandling - - /// Called when the agent is ready and the conversation can begin - public var onAgentReady: (@Sendable () -> Void)? - - /// Called when the agent disconnects or the conversation ends - public var onDisconnect: (@Sendable (DisconnectionReason) -> Void)? - - /// Called whenever the startup state transitions - public var onStartupStateChange: (@Sendable (ConversationStartupState) -> Void)? - - /// Controls timings and retry behavior for the initialization handshake - public var startupConfiguration: ConversationStartupConfiguration - - /// Controls microphone pipeline behaviour and VAD callbacks. - public var audioConfiguration: AudioPipelineConfiguration? - - /// Controls LiveKit peer connection behaviour, including ICE policies. - public var networkConfiguration: LiveKitNetworkConfiguration - - /// Called when a startup-related error occurs - public var onError: (@Sendable (ConversationError) -> Void)? - - /// Called when LiveKit detects speech activity while muted. - public var onSpeechActivity: (@Sendable (SpeechActivityEvent) -> Void)? - - /// Called for each agent response (finalized transcript) with its event identifier. - public var onAgentResponse: (@Sendable (_ text: String, _ eventId: Int) -> Void)? - - /// Called when an agent response is corrected. - public var onAgentResponseCorrection: (@Sendable (_ original: String, _ corrected: String, _ eventId: Int) -> Void)? - - /// Called when agent response metadata is received. - public var onAgentResponseMetadata: (@Sendable (_ metadataData: Data, _ eventId: Int) -> Void)? - - /// Called for each user transcript event emitted by the server. - public var onUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? - - /// Called whenever conversation metadata is received. - public var onConversationMetadata: (@Sendable (ConversationMetadataEvent) -> Void)? - - /// Called when the agent issues a tool response. - public var onAgentToolResponse: (@Sendable (AgentToolResponseEvent) -> Void)? - - /// Called when the agent requests a tool execution. - public var onAgentToolRequest: (@Sendable (AgentToolRequestEvent) -> Void)? - - /// Called when the agent detects an interruption. - public var onInterruption: (@Sendable (_ eventId: Int) -> Void)? - - /// Called whenever the server emits a VAD score. - public var onVadScore: (@Sendable (_ score: Double) -> Void)? - - /// Called when the agent emits audio alignment metadata for spoken words. - public var onAudioAlignment: (@Sendable (AudioAlignment) -> Void)? - - /// Called when the client should enable/disable feedback UI. - public var onCanSendFeedbackChange: (@Sendable (Bool) -> Void)? - - /// Called when an unhandled client tool call is received. - public var onUnhandledClientToolCall: (@Sendable (ClientToolCallEvent) -> Void)? - - /// When provided, agent state is computed from VAD scores and protocol events - /// instead of relying on LiveKit's isSpeaking detection. - public var agentStateConfiguration: AgentStateConfiguration? - - /// Called whenever the agent state changes (event-based mode only). - public var onAgentStateChange: (@Sendable (ElevenLabs.AgentState) -> Void)? - - public init( - conversationOverrides: ConversationOverrides = .init(), - agentOverrides: AgentOverrides? = nil, - ttsOverrides: TTSOverrides? = nil, - customLlmExtraBody: [String: String]? = nil, - dynamicVariables: [String: String]? = nil, - userId: String? = nil, - environment: String? = nil, - microphoneFailureHandling: MicrophoneFailureHandling = .throwError, - onAgentReady: (@Sendable () -> Void)? = nil, - onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil, - onStartupStateChange: (@Sendable (ConversationStartupState) -> Void)? = nil, - startupConfiguration: ConversationStartupConfiguration = .default, - audioConfiguration: AudioPipelineConfiguration? = nil, - networkConfiguration: LiveKitNetworkConfiguration = .default, - onError: (@Sendable (ConversationError) -> Void)? = nil, - onSpeechActivity: (@Sendable (SpeechActivityEvent) -> Void)? = nil, - onAgentResponse: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil, - onAgentResponseCorrection: (@Sendable (_ original: String, _ corrected: String, _ eventId: Int) -> Void)? = nil, - onAgentResponseMetadata: (@Sendable (_ metadataData: Data, _ eventId: Int) -> Void)? = nil, - onUserTranscript: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil, - onConversationMetadata: (@Sendable (ConversationMetadataEvent) -> Void)? = nil, - onAgentToolResponse: (@Sendable (AgentToolResponseEvent) -> Void)? = nil, - onAgentToolRequest: (@Sendable (AgentToolRequestEvent) -> Void)? = nil, - onInterruption: (@Sendable (_ eventId: Int) -> Void)? = nil, - onVadScore: (@Sendable (_ score: Double) -> Void)? = nil, - onAudioAlignment: (@Sendable (AudioAlignment) -> Void)? = nil, - onCanSendFeedbackChange: (@Sendable (Bool) -> Void)? = nil, - onUnhandledClientToolCall: (@Sendable (ClientToolCallEvent) -> Void)? = nil, - agentStateConfiguration: AgentStateConfiguration? = nil, - onAgentStateChange: (@Sendable (ElevenLabs.AgentState) -> Void)? = nil - ) { - self.conversationOverrides = conversationOverrides - self.agentOverrides = agentOverrides - self.ttsOverrides = ttsOverrides - self.customLlmExtraBody = customLlmExtraBody - self.dynamicVariables = dynamicVariables - self.userId = userId - self.environment = environment - self.microphoneFailureHandling = microphoneFailureHandling - self.onAgentReady = onAgentReady - self.onDisconnect = onDisconnect - self.onStartupStateChange = onStartupStateChange - self.startupConfiguration = startupConfiguration - self.audioConfiguration = audioConfiguration - self.networkConfiguration = networkConfiguration - self.onError = onError - self.onSpeechActivity = onSpeechActivity - self.onAgentResponse = onAgentResponse - self.onAgentResponseCorrection = onAgentResponseCorrection - self.onAgentResponseMetadata = onAgentResponseMetadata - self.onUserTranscript = onUserTranscript - self.onConversationMetadata = onConversationMetadata - self.onAgentToolResponse = onAgentToolResponse - self.onAgentToolRequest = onAgentToolRequest - self.onInterruption = onInterruption - self.onVadScore = onVadScore - self.onAudioAlignment = onAudioAlignment - self.onCanSendFeedbackChange = onCanSendFeedbackChange - self.onUnhandledClientToolCall = onUnhandledClientToolCall - self.agentStateConfiguration = agentStateConfiguration - self.onAgentStateChange = onAgentStateChange - } - - public static let `default` = ConversationOptions() -} - -extension ConversationOptions { - func toConversationConfig() -> ConversationConfig { - ConversationConfig( - agentOverrides: agentOverrides, - ttsOverrides: ttsOverrides, - conversationOverrides: conversationOverrides, - customLlmExtraBody: customLlmExtraBody, - dynamicVariables: dynamicVariables, - userId: userId, - environment: environment, - onAgentReady: onAgentReady, - onDisconnect: onDisconnect, - onStartupStateChange: onStartupStateChange, - startupConfiguration: startupConfiguration, - audioConfiguration: audioConfiguration, - networkConfiguration: networkConfiguration, - onError: onError, - onSpeechActivity: onSpeechActivity - ) - } -} diff --git a/Sources/ElevenLabs/Public/Conversation/Models/CallInfo.swift b/Sources/ElevenLabs/Public/Conversation/Models/CallInfo.swift deleted file mode 100644 index 3cdcdf97..00000000 --- a/Sources/ElevenLabs/Public/Conversation/Models/CallInfo.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -public struct CallInfo: Equatable, Sendable { - public let agentId: String -} diff --git a/Sources/ElevenLabs/Public/ConversationClient.swift b/Sources/ElevenLabs/Public/ConversationClient.swift new file mode 100644 index 00000000..507e59e8 --- /dev/null +++ b/Sources/ElevenLabs/Public/ConversationClient.swift @@ -0,0 +1,256 @@ +import Combine +import Foundation + +/// The durable, observable handle for talking to an ElevenLabs agent. +/// +/// Create one and hold it for the lifetime of your screen (e.g. a SwiftUI +/// `@StateObject`). It exposes live conversation state as `@Published` +/// properties and controls as methods, so a view can bind to it directly: +/// +/// ```swift +/// @StateObject private var client = ConversationClient() +/// +/// var body: some View { +/// List(client.messages) { MessageBubble($0) } +/// Button("Start") { +/// Task { try await client.start(auth: .publicAgent(id: "agent_123")) } +/// } +/// } +/// ``` +/// +/// Each ``start(auth:config:)`` runs a fresh single-use session internally; the +/// client is reusable โ€” call `start` again for another. +@MainActor +public final class ConversationClient: ObservableObject { + // MARK: - Published state (mirrored from the active session) + + /// Connection lifecycle of the current session. `.idle` before the first + /// ``start(auth:config:)``. + @Published public private(set) var state: ConversationState = .idle + + /// The conversation transcript (canonical, reconciled by the SDK). + @Published public private(set) var messages: [Message] = [] + + /// Whether the agent is currently speaking. + @Published public private(set) var isAgentSpeaking: Bool = false + + /// Heuristic agent state (`.listening`/`.speaking`/`.thinking`). Event-based + /// (VAD + client events) when `ConversationConfig.agentStateConfiguration` is + /// set; otherwise mirrors `isAgentSpeaking`. + @Published public private(set) var agentState: AgentState = .listening + + /// Whether the **microphone (input)** is muted. Unrelated to agent output. + @Published public private(set) var isMicMuted: Bool = true + + /// Whether the user is currently speaking while the microphone is muted. + /// Only fires for mute modes that support detection (see ``MicrophoneMuteMode``). + @Published public private(set) var isSpeakingWhileMuted: Bool = false + + /// Whether the **agent's audio output (playback)** is muted. + @Published public private(set) var isAgentMuted: Bool = false + + /// Client tool calls awaiting execution or local completion by your app. + @Published public private(set) var pendingToolCalls: [ClientToolCallEvent] = [] + + /// Server-reported metadata for the current conversation (id, etc.). + @Published public private(set) var conversationMetadata: ConversationMetadataEvent? + + /// MCP tool calls (including any awaiting your approval). + @Published public private(set) var mcpToolCalls: [MCPToolCallEvent] = [] + + /// Status of the agent's MCP server connection(s). + @Published public private(set) var mcpConnectionStatus: MCPConnectionStatusEvent? + + // MARK: - Dependencies & per-session wiring + + private let callbacks: ConversationCallbacks + /// Test seam: when non-nil, sessions use this provider's mock connection + /// managers instead of a live `Dependencies`. + private let dependencyProvider: (any ConversationDependencyProvider)? + + /// The current single-use session. Internal plumbing โ€” never exposed. + private var session: Conversation? + /// Subscriptions mirroring the active session's state. Reset on each + /// ``start(auth:config:)`` so mirrors follow the live session. + private var cancellables = Set() + + // MARK: - Init + + /// Create a client. `callbacks` apply to every session it starts. The + /// per-session configuration is supplied at ``start(auth:config:)``. + public init( + callbacks: ConversationCallbacks = .init() + ) { + self.callbacks = callbacks + self.dependencyProvider = nil + } + + /// Test-only initializer that injects a dependency provider. + init( + callbacks: ConversationCallbacks = .init(), + dependencyProvider: any ConversationDependencyProvider + ) { + self.callbacks = callbacks + self.dependencyProvider = dependencyProvider + } + + // MARK: - Lifecycle + + /// Start a new conversation. Any currently-active conversation is ended + /// first, then a fresh single-use session is created and connected. `auth` is + /// consumed here so tokens with a TTL stay fresh. + /// + /// - Important: Call `start` serially โ€” do not invoke it again while a prior + /// `start` is still in flight (its `await` has not returned). Although this + /// type is `@MainActor`, `start` spans suspension points, so two + /// overlapping calls can interleave: the later call rebinds the client to a + /// new session and orphans the first still-connecting session (a leaked + /// transport with no handle left to end it). Drive `start`/`endConversation` + /// from a single owner (e.g. one screen's view model) and await each before + /// beginning the next. + /// + /// - Parameters: + /// - auth: Authentication for this session. + /// - config: Configuration for this session. Defaults to ``ConversationConfig/default``. + public func start( + auth: ConversationAuth, + config: ConversationConfig = .default + ) async throws { + if let session, !session.state.isInactive { + await session.endConversation() + } + + let conversation = Conversation( + dependencyProvider: dependencyProvider, + config: config, + callbacks: callbacks + ) + bind(conversation) + // `bind` synchronously seeds the mirrors from the session's `.idle` + // defaults; connect then drives them through the startup transitions. + try await conversation.connect(auth: auth) + } + + /// End the current conversation, if any. The transcript and terminal state + /// remain published until the next ``start(auth:config:)``. + public func endConversation() async { + await session?.endConversation() + } + + /// Clear a terminated session (``ConversationState/startupFailed(_:)`` or + /// ``ConversationState/ended(reason:)``) back to ``ConversationState/idle``, + /// discarding the published transcript and metadata. Use it to dismiss an + /// error or a finished transcript without starting a new conversation. A + /// no-op while idle or while a session is connecting/connected โ€” end it first. + public func reset() { + session?.reset() + } + + /// Mirror the new session's `@Published` state onto this object. + private func bind(_ session: Conversation) { + cancellables.removeAll() + self.session = session + + session.$state.sink { [weak self] in self?.state = $0 }.store(in: &cancellables) + session.$messages.sink { [weak self] in self?.messages = $0 }.store(in: &cancellables) + session.$isAgentSpeaking.sink { [weak self] in self?.isAgentSpeaking = $0 }.store(in: &cancellables) + session.$agentState.sink { [weak self] in self?.agentState = $0 }.store(in: &cancellables) + session.$isMicMuted.sink { [weak self] in self?.isMicMuted = $0 }.store(in: &cancellables) + session.$isSpeakingWhileMuted.sink { [weak self] in self?.isSpeakingWhileMuted = $0 }.store(in: &cancellables) + session.$isAgentMuted.sink { [weak self] in self?.isAgentMuted = $0 }.store(in: &cancellables) + session.$pendingToolCalls.sink { [weak self] in self?.pendingToolCalls = $0 }.store(in: &cancellables) + session.$conversationMetadata.sink { [weak self] in self?.conversationMetadata = $0 }.store(in: &cancellables) + session.$mcpToolCalls.sink { [weak self] in self?.mcpToolCalls = $0 }.store(in: &cancellables) + session.$mcpConnectionStatus.sink { [weak self] in self?.mcpConnectionStatus = $0 }.store(in: &cancellables) + } + + private func requireSession() throws -> Conversation { + guard let session else { throw ConversationError.notConnected } + return session + } + + // MARK: - Messaging + + /// Send a text message to the agent. + public func sendMessage(_ text: String) async throws { + try await requireSession().sendMessage(text) + } + + /// Interrupt the agent while it is speaking. + public func interruptAgent() async throws { + try await requireSession().interruptAgent() + } + + /// Send a silent contextual update to the agent (no user-visible message). + public func updateContext(_ context: String) async throws { + try await requireSession().updateContext(context) + } + + // MARK: - Microphone (input) mute + + /// Toggle the local microphone mute state. A best-effort no-op when there is + /// no live session, matching the agent-mute controls. + public func toggleMicMute() async throws { + try await session?.toggleMicMute() + } + + /// Mute or unmute the local microphone. A best-effort no-op when there is no + /// live session, matching the agent-mute controls. + public func setMicMuted(_ muted: Bool) async throws { + try await session?.setMicMuted(muted) + } + + // MARK: - Agent (output) mute + + /// Toggle whether the agent's audio output (playback) is muted. + public func toggleAgentMute() { + session?.toggleAgentMute() + } + + /// Mute or unmute the agent's audio output (playback). + public func setAgentMuted(_ muted: Bool) { + session?.setAgentMuted(muted) + } + + // MARK: - Tools & feedback + + /// Approve or reject an MCP tool-call request from the agent. + public func sendMCPToolApproval(toolCallId: String, isApproved: Bool) async throws { + try await requireSession().sendMCPToolApproval(toolCallId: toolCallId, isApproved: isApproved) + } + + /// Send the result of a client tool call back to the agent. + public func sendToolResult( + for toolCallId: String, + result: Any, + isError: Bool = false, + errorType: ClientToolErrorType? = nil + ) async throws { + try await requireSession().sendToolResult( + for: toolCallId, + result: result, + isError: isError, + errorType: errorType + ) + } + + /// Mark a tool call as completed without sending a result. + public func markToolCallCompleted(_ toolCallId: String) { + session?.markToolCallCompleted(toolCallId) + } + + /// Send in-conversation feedback (like/dislike) for an agent message. + /// + /// - Parameters: + /// - score: `.like` or `.dislike`. + /// - eventId: The `eventId` of the agent ``Message`` being rated โ€” every agent + /// message in ``messages`` carries one. + /// + /// Throws ``ConversationError/notConnected`` if not connected. The server is + /// last-write-wins and accepts any past agent `eventId` (re-rating overwrites; + /// an unknown id is a no-op), so the SDK doesn't gate availability โ€” any + /// "rate once" UI behaviour is your app's concern. + public func sendFeedback(_ score: FeedbackEvent.Score, eventId: Int) async throws { + try await requireSession().sendFeedback(score, eventId: eventId) + } +} diff --git a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+AgentState.swift b/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+AgentState.swift deleted file mode 100644 index 52ac3a5f..00000000 --- a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+AgentState.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -extension ElevenLabs { - /// Agent state indicating what the agent is currently doing. - public enum AgentState: Sendable, Equatable { - /// Agent is listening to the user - case listening - /// Agent is speaking - case speaking - /// Agent is thinking (e.g. preparing a tool call) - case thinking - } -} diff --git a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+Configuration.swift b/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+Configuration.swift deleted file mode 100644 index 6deb3a36..00000000 --- a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+Configuration.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -extension ElevenLabs { - /// Global SDK configuration. - public struct Configuration: Sendable { - public let apiEndpoint: URL? - public let websocketUrl: String? - public let logLevel: LogLevel - public let debugMode: Bool - - public init( - apiEndpoint: URL? = nil, - websocketUrl: String? = nil, - logLevel: LogLevel = .warning, - debugMode: Bool = false - ) { - self.apiEndpoint = apiEndpoint - self.websocketUrl = websocketUrl - self.logLevel = logLevel - self.debugMode = debugMode - } - - public static let `default` = Configuration() - - /// Create a new configuration with updated values (builder pattern) - public func with( - apiEndpoint: URL? = nil, - websocketUrl: String? = nil, - logLevel: LogLevel? = nil, - debugMode: Bool? = nil - ) -> Configuration { - Configuration( - apiEndpoint: apiEndpoint ?? self.apiEndpoint, - websocketUrl: websocketUrl ?? self.websocketUrl, - logLevel: logLevel ?? self.logLevel, - debugMode: debugMode ?? self.debugMode - ) - } - } -} diff --git a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+LogLevel.swift b/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+LogLevel.swift deleted file mode 100644 index ca17c226..00000000 --- a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs+LogLevel.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -extension ElevenLabs { - /// Logging level for SDK internal diagnostics - public enum LogLevel: Int, Comparable, Sendable { - case error = 0 - case warning = 1 - case info = 2 - case debug = 3 - case trace = 4 - - public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { - lhs.rawValue < rhs.rawValue - } - } -} diff --git a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs.swift b/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs.swift deleted file mode 100644 index 88f3c411..00000000 --- a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabs.swift +++ /dev/null @@ -1,238 +0,0 @@ -import Foundation -import LiveKit - -// Main namespace & entry point for the ElevenLabs Conversational AI SDK. -// -// ```swift -// // Start a conversation directly - simple and clean -// let conversation = try await ElevenLabs.startConversation( -// agentId: "agent_123", -// config: .init(conversationOverrides: .init(textOnly: false)) -// ) -// -// // Send a message -// try await conversation.sendMessage("Hello!") -// -// // End the conversation -// await conversation.endConversation() -// ``` - -public enum ElevenLabs { - // MARK: - Version - - public static let version = "3.2.2" - - // MARK: - Configuration - - /// Global, optional SDK configuration. Provide once at app start. - /// If you never call `configure(_:)`, sensible defaults are used. - @MainActor - public static func configure(_ configuration: Configuration) { - Global.shared.configuration = configuration - } - - // MARK: - SDK interface - - /// Start a conversation with an ElevenLabs agent using a public agent ID - the most common use case. - /// - /// This method handles all the complexity of connection setup, authentication, - /// and protocol initialization. Simply provide a public agent ID and optional configuration. - /// - /// - Parameters: - /// - agentId: The public ElevenLabs agent ID to connect to - /// - config: Optional conversation configuration (voice/text mode, overrides, etc.) - /// - onAgentReady: Optional callback triggered when the agent is ready and conversation can begin - /// - onDisconnect: Optional callback triggered when the agent disconnects or conversation ends - /// - Returns: An active `Conversation` instance ready for interaction - /// - Throws: `ConversationError` if connection fails, agent not found, or configuration invalid - /// - /// ```swift - /// // Voice conversation (default) - simplest usage - /// let conversation = try await ElevenLabs.startConversation(agentId: "agent_123") - /// - /// // Text-only conversation - /// let textConversation = try await ElevenLabs.startConversation( - /// agentId: "agent_123", - /// config: .init(conversationOverrides: .init(textOnly: true)) - /// ) - /// - /// // Conversation with event handlers - /// let conversation = try await ElevenLabs.startConversation( - /// agentId: "agent_123", - /// onAgentReady: { - /// print("Agent is ready!") - /// }, - /// onDisconnect: { - /// print("Agent disconnected") - /// } - /// ) - /// ``` - @MainActor - public static func startConversation( - agentId: String, - config: ConversationConfig = .init(), - onAgentReady: (@Sendable () -> Void)? = nil, - onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil - ) async throws -> Conversation { - let authConfig = ElevenLabsConfiguration.publicAgent(id: agentId, environment: config.environment) - var updatedConfig = config - updatedConfig.onAgentReady = onAgentReady - updatedConfig.onDisconnect = onDisconnect - return try await startConversation(auth: authConfig, config: updatedConfig) - } - - /// Start a conversation using a conversation token from your backend - for private agents. - /// - /// Use this method when you have private agents that require authentication. - /// Your backend should generate conversation tokens using your ElevenLabs API key. - /// - /// Security: Never include your ElevenLabs API key in client apps! - /// - /// - Parameters: - /// - conversationToken: The conversation token from your backend - /// - config: Optional conversation configuration (voice/text mode, overrides, etc.) - /// - onAgentReady: Optional callback triggered when the agent is ready and conversation can begin - /// - onDisconnect: Optional callback triggered when the agent disconnects or conversation ends - /// - Returns: An active `Conversation` instance ready for interaction - /// - Throws: `ConversationError` if connection fails or token is invalid - /// - /// ```swift - /// // Get token from your backend - /// let token = try await fetchTokenFromMyBackend() - /// - /// // Start conversation with private agent - /// let conversation = try await ElevenLabs.startConversation( - /// conversationToken: token, - /// config: .init( - /// agentOverrides: .init(firstMessage: "Hello! How can I help you today?") - /// ) - /// ) - /// ``` - @MainActor - public static func startConversation( - conversationToken: String, - config: ConversationConfig = .init(), - onAgentReady: (@Sendable () -> Void)? = nil, - onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil - ) async throws -> Conversation { - let authConfig = ElevenLabsConfiguration.conversationToken(conversationToken, environment: config.environment) - var updatedConfig = config - updatedConfig.onAgentReady = onAgentReady - updatedConfig.onDisconnect = onDisconnect - return try await startConversation(auth: authConfig, config: updatedConfig) - } - - /// Start a conversation using a custom token provider - for advanced authentication scenarios. - /// - /// Use this method when you need dynamic token generation or complex authentication flows. - /// - /// - Parameters: - /// - tokenProvider: An async closure that returns a conversation token - /// - config: Optional conversation configuration (voice/text mode, overrides, etc.) - /// - onAgentReady: Optional callback triggered when the agent is ready and conversation can begin - /// - onDisconnect: Optional callback triggered when the agent disconnects or conversation ends - /// - Returns: An active `Conversation` instance ready for interaction - /// - Throws: `ConversationError` if connection fails or token provider throws - /// - /// ```swift - /// // Dynamic token provider - /// let conversation = try await ElevenLabs.startConversation( - /// tokenProvider: { - /// // Your custom authentication logic - /// let userAuth = try await authenticateUser() - /// return try await fetchElevenLabsToken(for: userAuth) - /// }, - /// config: .init(conversationOverrides: .init(textOnly: false)) - /// ) - /// ``` - @MainActor - public static func startConversation( - tokenProvider: @escaping @Sendable () async throws -> String, - config: ConversationConfig = .init(), - onAgentReady: (@Sendable () -> Void)? = nil, - onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil - ) async throws -> Conversation { - let authConfig = ElevenLabsConfiguration.customTokenProvider(tokenProvider, environment: config.environment) - var updatedConfig = config - updatedConfig.onAgentReady = onAgentReady - updatedConfig.onDisconnect = onDisconnect - return try await startConversation(auth: authConfig, config: updatedConfig) - } - - /// Start a text-only conversation using a signed WebSocket URL from your backend. - /// - /// Use this for private/non-public agents in text-only mode. Signed URLs should be generated - /// server-side using your ElevenLabs API key. - @MainActor - public static func startConversation( - signedWebSocketURL: String, - config: ConversationConfig = .init(conversationOverrides: .init(textOnly: true)), - onAgentReady: (@Sendable () -> Void)? = nil, - onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil - ) async throws -> Conversation { - let authConfig = try ElevenLabsConfiguration.signedWebSocketURL(signedWebSocketURL) - var updatedConfig = config - var overrides = updatedConfig.conversationOverrides ?? ConversationOverrides() - overrides.textOnly = true - updatedConfig.conversationOverrides = overrides - updatedConfig.onAgentReady = onAgentReady - updatedConfig.onDisconnect = onDisconnect - return try await startConversation(auth: authConfig, config: updatedConfig) - } - - /// Advanced: Start a conversation with full authentication control. - /// - /// This is the most flexible method that all other convenience methods use internally. - /// Most developers should use the simpler `startConversation(agentId:)` method instead. - /// - /// - Parameters: - /// - auth: The authentication configuration - /// - config: Optional conversation configuration - /// - Returns: An active `Conversation` instance ready for interaction - @MainActor - public static func startConversation( - auth: ElevenLabsConfiguration, - config: ConversationConfig = .init() - ) async throws -> Conversation { - let options = config.toConversationOptions() - let conversation = createConversation(options: options) - try await conversation.startConversation( - auth: auth, options: options - ) - return conversation - } - - // MARK: - Internal Factory Methods - - /// Creates a new Conversation instance with proper dependency injection. - @MainActor - private static func createConversation(options: ConversationOptions = .default) -> Conversation { - Conversation(dependencyProvider: Dependencies(), options: options) - } - - // MARK: - Re-exports - - // Protocol event types are already public from their respective files - public typealias SpeechActivityEvent = LiveKit.SpeechActivityEvent - public typealias MicrophoneMuteMode = LiveKit.MicrophoneMuteMode - public typealias IceTransportPolicy = LiveKit.IceTransportPolicy - public typealias IceServer = LiveKit.IceServer - - // Re-export audio track types for advanced audio handling - public typealias LocalAudioTrack = LiveKit.LocalAudioTrack - public typealias RemoteAudioTrack = LiveKit.RemoteAudioTrack - public typealias AudioTrack = LiveKit.AudioTrack - - // Language enum is already public and accessible as ElevenLabs.Language - - // MARK: - Internal Global State - - /// Internal container for global (process-wide) configuration. - /// This mimics the old `Dependencies` singleton but keeps it internal. - @MainActor - final class Global { - static let shared = Global() - var configuration: Configuration = .default - private init() {} - } -} diff --git a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabsConfiguration.swift b/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabsConfiguration.swift deleted file mode 100644 index 01155a65..00000000 --- a/Sources/ElevenLabs/Public/ElevenLabs/ElevenLabsConfiguration.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation - -/// Configuration for ElevenLabs conversational AI -public struct ElevenLabsConfiguration: Sendable { - /// The source of authentication for the conversation - public enum AuthSource: Sendable { - /// Use a public agent ID (no authentication required) - case publicAgentId(String) - /// Use a conversation token from your backend - case conversationToken(String) - /// Use a signed WebSocket URL from your backend (agent ID parsed from the URL) - case signedWebSocketURL(url: String, agentId: String) - /// Custom token provider for advanced use cases - case customTokenProvider(@Sendable () async throws -> String) - } - - public let authSource: AuthSource - public let participantName: String - /// Optional environment for the agent (defaults to production when nil) - public let environment: String? - public var agentId: String { - switch authSource { - case let .publicAgentId(id): - id - case let .signedWebSocketURL(_, agentId): - agentId - case .conversationToken, .customTokenProvider: - "unknown" - } - } - - /// Initialize with a public agent ID - public static func publicAgent(id: String, participantName: String = "user", environment: String? = nil) -> Self { - .init(authSource: .publicAgentId(id), participantName: participantName, environment: environment) - } - - /// Initialize with a conversation token - public static func conversationToken(_ token: String, participantName: String = "user", environment: String? = nil) -> Self { - .init(authSource: .conversationToken(token), participantName: participantName, environment: environment) - } - - /// Initialize with a signed WebSocket URL generated by your backend. - /// Throws if the URL is missing the required `agent_id` query parameter. - public static func signedWebSocketURL(_ url: String, participantName: String = "user") throws -> Self { - guard - let agentId = URLComponents(string: url)? - .queryItems? - .first(where: { $0.name == "agent_id" })? - .value, - !agentId.isEmpty - else { - throw ConversationError.authenticationFailed( - "Signed WebSocket URL is missing the agent_id query parameter." - ) - } - return .init( - authSource: .signedWebSocketURL(url: url, agentId: agentId), - participantName: participantName, - environment: nil - ) - } - - /// Initialize with a custom token provider - public static func customTokenProvider( - _ provider: @escaping @Sendable () async throws -> String, - participantName: String = "user", - environment: String? = nil - ) -> Self { - .init(authSource: .customTokenProvider(provider), participantName: participantName, environment: environment) - } -} diff --git a/Tests/ElevenLabsTests/ElevenLabsTests.swift b/Tests/ElevenLabsTests/ElevenLabsTests.swift index 17617f93..e1cf91ee 100644 --- a/Tests/ElevenLabsTests/ElevenLabsTests.swift +++ b/Tests/ElevenLabsTests/ElevenLabsTests.swift @@ -2,17 +2,17 @@ import XCTest final class ElevenLabsTests: XCTestCase { - func testConfigurationDefault() { - let config = ElevenLabs.Configuration.default - XCTAssertNil(config.apiEndpoint) - XCTAssertEqual(config.logLevel, .warning) - XCTAssertFalse(config.debugMode) + func testDefaultEndpoints() { + let endpoints = ElevenLabsEndpoints.production + XCTAssertEqual(endpoints.voiceWebSocket.absoluteString, "wss://livekit.rtc.elevenlabs.io") + XCTAssertEqual(endpoints.textWebSocket.absoluteString, "wss://api.elevenlabs.io/v1/convai/conversation") + XCTAssertEqual(endpoints.apiBase.absoluteString, "https://api.elevenlabs.io") } func testConversationConfigInit() { let config = ConversationConfig() XCTAssertNil(config.agentOverrides) XCTAssertNil(config.ttsOverrides) - XCTAssertNil(config.conversationOverrides) + XCTAssertFalse(config.textOnly) } } diff --git a/Tests/ElevenLabsTests/Integration/ConversationIntegrationTests.swift b/Tests/ElevenLabsTests/Integration/ConversationIntegrationTests.swift index 8f262c2b..2e3164c1 100644 --- a/Tests/ElevenLabsTests/Integration/ConversationIntegrationTests.swift +++ b/Tests/ElevenLabsTests/Integration/ConversationIntegrationTests.swift @@ -28,8 +28,8 @@ final class ConversationIntegrationTests: XCTestCase { XCTAssertEqual(config.agentOverrides?.language, .english) } - func testConversationStateTransitions() async { - let conversation = Conversation(dependencyProvider: Dependencies()) + func testConversationStateTransitions() async throws { + let conversation = Conversation() // Initial state XCTAssertEqual(conversation.state, .idle) @@ -40,10 +40,10 @@ final class ConversationIntegrationTests: XCTestCase { try await conv.sendMessage("Hello") } - // Mute operations also require active state - await assertThrowsConversationError(.notConnected) { - try await conv.toggleMute() - } + // Mic mute is lenient: a best-effort no-op when not connected. + let micMutedBefore = conv.isMicMuted + try await conv.toggleMicMute() + XCTAssertEqual(conv.isMicMuted, micMutedBefore, "Mic mute must be a no-op when not connected") await assertThrowsConversationError(.notConnected) { try await conv.interruptAgent() @@ -51,7 +51,7 @@ final class ConversationIntegrationTests: XCTestCase { } func testMessageStreamHandling() { - let conversation = Conversation(dependencyProvider: Dependencies()) + let conversation = Conversation() // Test that message streams are empty initially XCTAssertTrue(conversation.messages.isEmpty) @@ -63,21 +63,15 @@ final class ConversationIntegrationTests: XCTestCase { // - Verify message ordering } - func testAudioIntegration() async { - let conversation = Conversation(dependencyProvider: Dependencies()) + func testAudioIntegration() async throws { + let conversation = Conversation() // Test initial mute state - XCTAssertTrue(conversation.isMuted) + XCTAssertTrue(conversation.isMicMuted) - // Test mute operations when not connected - should throw - do { - try await conversation.setMuted(true) - XCTFail("Should throw error when not connected") - } catch let error as ConversationError { - XCTAssertEqual(error, .notConnected) - } catch { - XCTFail("Unexpected error type") - } + // Mic mute is lenient when not connected: a best-effort no-op, not an error. + try await conversation.setMicMuted(false) + XCTAssertTrue(conversation.isMicMuted, "Mic mute must be a no-op when not connected") // In a real integration test: // - Test microphone permissions @@ -87,7 +81,7 @@ final class ConversationIntegrationTests: XCTestCase { } func testToolCallIntegration() async { - let conversation = Conversation(dependencyProvider: Dependencies()) + let conversation = Conversation() // Test tool response when not connected let conv2 = conversation @@ -107,7 +101,7 @@ final class ConversationIntegrationTests: XCTestCase { } func testContextUpdateIntegration() async { - let conversation = Conversation(dependencyProvider: Dependencies()) + let conversation = Conversation() // Test context update when not connected let conv3 = conversation @@ -123,7 +117,7 @@ final class ConversationIntegrationTests: XCTestCase { } func testFeedbackIntegration() async { - let conversation = Conversation(dependencyProvider: Dependencies()) + let conversation = Conversation() // Test feedback when not connected let conv4 = conversation @@ -151,7 +145,7 @@ final class ConversationIntegrationTests: XCTestCase { } func testConcurrentOperations() async { - let conversation = Conversation(dependencyProvider: Dependencies()) + let conversation = Conversation() // Test that multiple operations handle not-connected state consistently await withTaskGroup(of: Void.self) { group in @@ -175,7 +169,7 @@ final class ConversationIntegrationTests: XCTestCase { weak var weakConversation: Conversation? do { - let conversation = Conversation(dependencyProvider: Dependencies()) + let conversation = Conversation() weakConversation = conversation // In a real test, we'd start and end conversation diff --git a/Tests/ElevenLabsTests/Mocks/Conversation+TestStart.swift b/Tests/ElevenLabsTests/Mocks/Conversation+TestStart.swift new file mode 100644 index 00000000..b6efe5d7 --- /dev/null +++ b/Tests/ElevenLabsTests/Mocks/Conversation+TestStart.swift @@ -0,0 +1,10 @@ +@testable import ElevenLabs + +@MainActor +extension Conversation { + /// Test convenience mirroring the pre-redesign instance entry point. Config + /// is fixed at construction now; pass non-default config to `Conversation(...)`. + func startConversation(auth: ConversationAuth, config _: ConversationConfig = .default) async throws { + try await connect(auth: auth) + } +} diff --git a/Tests/ElevenLabsTests/StartupPerformanceTest.swift b/Tests/ElevenLabsTests/StartupPerformanceTest.swift index 56e2d901..9179ce72 100644 --- a/Tests/ElevenLabsTests/StartupPerformanceTest.swift +++ b/Tests/ElevenLabsTests/StartupPerformanceTest.swift @@ -48,13 +48,12 @@ final class StartupPerformanceTest: XCTestCase { // Monitor state changes - create these before starting conversation var hasConnected = false var hasReceivedFirstMessage = false - var conversation: Conversation! + var conversation: ConversationClient! - // Start the conversation using the static API + // Start the conversation via the durable client print(" [\(String(format: "%.3f", 0.0))s] Starting conversation...") - conversation = try await ElevenLabs.startConversation( - agentId: "agent_4601k18km8yde6ftyzzwfdk6jvez" - ) + conversation = ConversationClient() + try await conversation.start(auth: .publicAgent(id: "agent_4601k18km8yde6ftyzzwfdk6jvez")) // Since the static API already handles the startup, just monitor the result // Check the current state immediately @@ -66,14 +65,14 @@ final class StartupPerformanceTest: XCTestCase { print(" [\(String(format: "%.3f", elapsed))s] State: idle") case .connecting: print(" [\(String(format: "%.3f", elapsed))s] State: connecting") - case let .active(info): + case .connected: hasConnected = true - print(" [\(String(format: "%.3f", elapsed))s] State: active (agent: \(info.agentId))") - print(" ๐ŸŽฏ ACTIVE STATE REACHED in \(String(format: "%.3f", elapsed))s") + print(" [\(String(format: "%.3f", elapsed))s] State: connected") + print(" ๐ŸŽฏ CONNECTED STATE REACHED in \(String(format: "%.3f", elapsed))s") case let .ended(reason): print(" [\(String(format: "%.3f", elapsed))s] State: ended (reason: \(reason))") - case let .error(error): - print(" [\(String(format: "%.3f", elapsed))s] State: error - \(error)") + case let .startupFailed(failure): + print(" [\(String(format: "%.3f", elapsed))s] State: startup failed - \(failure)") } // Check for existing messages @@ -85,7 +84,7 @@ final class StartupPerformanceTest: XCTestCase { } } - print(" [\(String(format: "%.3f", elapsed))s] Agent state: \(conversation.agentState)") + print(" [\(String(format: "%.3f", elapsed))s] Agent speaking: \(conversation.isAgentSpeaking)") // The static API should return an already-active conversation // But let's give it a moment and measure the total time when we called the API @@ -111,7 +110,7 @@ final class StartupPerformanceTest: XCTestCase { try await Task.sleep(nanoseconds: 500_000_000) // 0.5s // Check if we reached active state - let reachedActive = conversation.state.isActive + let reachedActive = conversation.state == .connected if case .ended(reason: .userEnded) = conversation.state { // This is fine - we ended it ourselves } else if !reachedActive { diff --git a/Tests/ElevenLabsTests/Unit/BusinessLogicTests.swift b/Tests/ElevenLabsTests/Unit/BusinessLogicTests.swift index f6b3ad50..6fac1816 100644 --- a/Tests/ElevenLabsTests/Unit/BusinessLogicTests.swift +++ b/Tests/ElevenLabsTests/Unit/BusinessLogicTests.swift @@ -25,19 +25,53 @@ final class ElevenLabsBusinessLogicTests: XCTestCase { // MARK: - Tool Call Tests + func testClientToolCallIsAppendedBeforeCallbackFires() async throws { + // Snapshot what a handler observes at the instant `onClientToolCall` + // fires. The callback is `@Sendable`; it runs synchronously on the main + // actor inside the handler, so `assumeIsolated` is safe here. + final class OrderBox: @unchecked Sendable { + weak var conversation: Conversation? + var pendingCountAtCallback: Int? + } + let box = OrderBox() + var callbacks = ConversationCallbacks() + callbacks.onClientToolCall = { _ in + MainActor.assumeIsolated { + box.pendingCountAtCallback = box.conversation?.pendingToolCalls.count + } + } + let conversation = Conversation(dependencyProvider: dependencyProvider, callbacks: callbacks) + box.conversation = conversation + conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) + conversation._testing_setState(.connected) + + let toolCall = try ClientToolCallEvent( + toolName: "test_tool", + toolCallId: "call_order", + parametersData: JSONSerialization.data(withJSONObject: ["arg": "val"]), + eventId: 1 + ) + await conversation._testing_handleIncomingEvent(.clientToolCall(toolCall)) + + XCTAssertEqual( + box.pendingCountAtCallback, 1, + "onClientToolCall must fire after the call is appended to pendingToolCalls" + ) + XCTAssertEqual(conversation.pendingToolCalls.first?.toolCallId, "call_order") + } + func testToolCallLifecycle() async throws { // Set up active state with room first mockWebRTCConnectionManager.room = Room() conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) - conversation._testing_setState(.active(CallInfo(agentId: "test"))) + conversation._testing_setState(.connected) // 1. Receive a tool call let toolCall = try ClientToolCallEvent( toolName: "test_tool", toolCallId: "call_123", parametersData: JSONSerialization.data(withJSONObject: ["arg": "val"]), - eventId: 1, - expectsResponse: false + eventId: 1 ) await conversation._testing_handleIncomingEvent(.clientToolCall(toolCall)) @@ -59,28 +93,27 @@ final class ElevenLabsBusinessLogicTests: XCTestCase { XCTAssertTrue(lastPayloadString.contains("success")) } - func testSendToolResultEncodesEncodableResult() async throws { - struct Weather: Encodable { - let temperature: Int - let condition: String - } - mockWebRTCConnectionManager.room = Room() - conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) - conversation._testing_setState(.active(CallInfo(agentId: "test"))) + // MARK: - Reset - try await conversation.sendToolResult( - for: "call_42", - result: Weather(temperature: 25, condition: "Sunny") - ) + func testResetClearsTerminalStateAndTranscript() async { + // Seed a transcript, then land in a terminal failed state. + let part = AgentChatResponsePartEvent(text: "Hello", type: .start, eventId: 1) + await conversation._testing_handleIncomingEvent(.agentChatResponsePart(part)) + XCTAssertEqual(conversation.messages.count, 1) + + conversation._testing_setState(.startupFailed(.token(.authenticationFailed("denied")))) + + conversation.reset() + + XCTAssertEqual(conversation.state, .idle) + XCTAssertTrue(conversation.messages.isEmpty, "reset() must clear the preserved transcript") + XCTAssertNil(conversation.conversationMetadata) + } - let payload = try XCTUnwrap(mockWebRTCConnectionManager.publishedPayloads.last) - let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: payload) as? [String: Any]) - XCTAssertEqual(envelope["type"] as? String, "client_tool_result") - // The Encodable value is JSON-encoded into the `result` string. - let resultString = try XCTUnwrap(envelope["result"] as? String) - let parsed = try JSONSerialization.jsonObject(with: XCTUnwrap(resultString.data(using: .utf8))) as? [String: Any] - XCTAssertEqual(parsed?["temperature"] as? Int, 25) - XCTAssertEqual(parsed?["condition"] as? String, "Sunny") + func testResetIsNoOpWhenIdle() { + XCTAssertEqual(conversation.state, .idle) + conversation.reset() + XCTAssertEqual(conversation.state, .idle) } // MARK: - Streaming Message Tests @@ -113,7 +146,7 @@ final class ElevenLabsBusinessLogicTests: XCTestCase { func testAutomaticEndCallHandling() async { mockWebRTCConnectionManager.room = Room() - conversation._testing_setState(.active(CallInfo(agentId: "test"))) + conversation._testing_setState(.connected) let toolResponse = AgentToolResponseEvent( toolName: "end_call", @@ -125,35 +158,38 @@ final class ElevenLabsBusinessLogicTests: XCTestCase { await conversation._testing_handleIncomingEvent(.agentToolResponse(toolResponse)) - // Verify conversation is still active (endConversation guards state.isActive so won't change from idle) - XCTAssertEqual(conversation.state, .active(CallInfo(agentId: "test"))) + // Verify conversation remains connected (end_call handling does not force-end here) + XCTAssertEqual(conversation.state, .connected) } // MARK: - Concurrency & Responsiveness func testStateTransitionsImmediatelyToConnecting() async throws { - // Simulate a previously ended conversation - conversation._testing_setState(.ended(reason: .userEnded)) - - // Start a new one + // A fresh (single-use) conversation starts in `.idle`. let startTask = Task { try await conversation.startConversation(auth: .publicAgent(id: "new-agent")) } // Check state immediately await Task.yield() - XCTAssertEqual(conversation.state, .connecting, "Should be connecting immediately, even if disconnect() is slow") + XCTAssertTrue(conversation.state.isConnecting, "Should be connecting immediately, even if disconnect() is slow") // Complete the start mockWebRTCConnectionManager.succeedAgentReady() try await startTask.value - XCTAssertEqual(conversation.state, .active(CallInfo(agentId: "new-agent"))) + XCTAssertEqual(conversation.state, .connected) } // MARK: - Audio Alignment - func testAudioAlignmentUpdatesProperty() async { + func testAudioAlignmentInvokesCallback() async { + final class AlignmentBox: @unchecked Sendable { var value: AudioAlignment? } + let box = AlignmentBox() + var callbacks = ConversationCallbacks() + callbacks.onAudioAlignment = { box.value = $0 } + let conversation = Conversation(dependencyProvider: dependencyProvider, callbacks: callbacks) + let alignment = AudioAlignment( chars: ["H", "e", "l", "l", "o"], charStartTimesMs: [0, 100, 200, 300, 400], @@ -163,28 +199,6 @@ final class ElevenLabsBusinessLogicTests: XCTestCase { await conversation._testing_handleIncomingEvent(.audio(audioEvent)) - XCTAssertEqual(conversation.latestAudioAlignment?.chars, ["H", "e", "l", "l", "o"]) - } - - func testEndConversationClearsLatestAudioState() async { - mockWebRTCConnectionManager.room = Room() - conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) - conversation._testing_setState(.active(CallInfo(agentId: "test"))) - - let alignment = AudioAlignment( - chars: ["H"], - charStartTimesMs: [0], - charDurationsMs: [100] - ) - let audioEvent = AudioEvent(audioBase64: "base64", eventId: 1, alignment: alignment) - - await conversation._testing_handleIncomingEvent(.audio(audioEvent)) - XCTAssertNotNil(conversation.latestAudioEvent) - XCTAssertNotNil(conversation.latestAudioAlignment) - - await conversation.endConversation() - - XCTAssertNil(conversation.latestAudioEvent) - XCTAssertNil(conversation.latestAudioAlignment) + XCTAssertEqual(box.value?.chars, ["H", "e", "l", "l", "o"]) } } diff --git a/Tests/ElevenLabsTests/Unit/ConversationTests.swift b/Tests/ElevenLabsTests/Unit/ConversationTests.swift index 07c59d3d..7c15238e 100644 --- a/Tests/ElevenLabsTests/Unit/ConversationTests.swift +++ b/Tests/ElevenLabsTests/Unit/ConversationTests.swift @@ -1,5 +1,6 @@ // swiftlint:disable file_length type_body_length @testable import ElevenLabs +import Combine import Foundation import LiveKit import XCTest @@ -20,7 +21,7 @@ final class ConversationTests: XCTestCase { webRTCConnectionManager: mockWebRTCConnectionManager, webSocketConnectionManager: mockWebSocketConnectionManager ) - conversation = Conversation(dependencyProvider: dependencyProvider) + conversation = makeConversation() await capturedErrors.reset() } @@ -35,26 +36,27 @@ final class ConversationTests: XCTestCase { @MainActor func testConversationInitialState() { XCTAssertEqual(conversation.state, .idle) - XCTAssertTrue(conversation.isMuted) + XCTAssertTrue(conversation.isMicMuted) XCTAssertTrue(conversation.messages.isEmpty) } - func testStartConversationSuccessUpdatesStartupState() async throws { - let stateExpectation = expectation(description: "startup becomes active") + func testStartConversationSuccessUpdatesState() async throws { + let stateExpectation = expectation(description: "state becomes connected") - let options = makeConfig( - onStartupStateChange: { state in - if case .active = state { - stateExpectation.fulfill() - } + conversation = makeConversation() + guard let conversation else { return } + + let cancellable = conversation.$state.sink { state in + if case .connected = state { + stateExpectation.fulfill() } - ) + } + defer { cancellable.cancel() } let startTask = Task { - guard let conversation = self.conversation else { return } try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: options + auth: .publicAgent(id: "test-agent-id"), + config: makeConfig() ) } @@ -66,13 +68,7 @@ final class ConversationTests: XCTestCase { XCTAssertEqual(mockWebRTCConnectionManager.connectCallCount, 1) XCTAssertFalse(mockWebRTCConnectionManager.publishedPayloads.isEmpty) - XCTAssertEqual(conversation.state, .active(.init(agentId: "test-agent-id"))) - guard case let .active(callInfo, metrics) = conversation.startupState else { - return XCTFail("Expected active startup state") - } - XCTAssertEqual(callInfo.agentId, "test-agent-id") - XCTAssertEqual(metrics.conversationInitAttempts, 1) - XCTAssertEqual(conversation.startupMetrics?.total, metrics.total) + XCTAssertEqual(conversation.state, .connected) let errorsAfterSuccess = await capturedErrors.values() XCTAssertTrue(errorsAfterSuccess.isEmpty) } @@ -81,8 +77,8 @@ final class ConversationTests: XCTestCase { let startTask = Task { guard let conversation = self.conversation else { return } try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: makeConfig() + auth: .publicAgent(id: "test-agent-id"), + config: makeConfig() ) } @@ -109,11 +105,13 @@ final class ConversationTests: XCTestCase { } func testStartConversationHandlesIncomingDataBeforeAgentReady() async throws { + // Drive metadata manually so the assertion observes the injected id. + mockWebRTCConnectionManager.autoDeliverMetadata = false let startTask = Task { guard let conversation = self.conversation else { return } try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: makeConfig() + auth: .publicAgent(id: "test-agent-id"), + config: makeConfig() ) } @@ -151,8 +149,8 @@ final class ConversationTests: XCTestCase { let startTask = Task { guard let conversation = self.conversation else { return } try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: makeConfig() + auth: .publicAgent(id: "test-agent-id"), + config: makeConfig() ) } @@ -176,8 +174,8 @@ final class ConversationTests: XCTestCase { let startTask = Task { guard let conversation = self.conversation else { return } try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: makeConfig() + auth: .publicAgent(id: "test-agent-id"), + config: makeConfig() ) } @@ -186,22 +184,19 @@ final class ConversationTests: XCTestCase { try await startTask.value XCTAssertNotNil(mockWebRTCConnectionManager.onRemoteSpeakingChanged) - XCTAssertFalse(conversation.isMuted) + XCTAssertFalse(conversation.isMicMuted) mockWebRTCConnectionManager.onRemoteSpeakingChanged?(true) try? await Task.sleep(nanoseconds: 100_000_000) - XCTAssertEqual(conversation.agentState, .speaking) + XCTAssertTrue(conversation.isAgentSpeaking) } func testStartTextOnlyPublicAgentUsesWebSocketConnectionManager() async throws { - let options = makeConfig(configure: { options in - options.conversationOverrides = ConversationOverrides(textOnly: true) - }) + conversation = makeConversation(config: makeConfig(textOnly: true)) try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: options + auth: .publicAgent(id: "test-agent-id") ) XCTAssertEqual(mockWebRTCConnectionManager.connectCallCount, 0) @@ -215,7 +210,7 @@ final class ConversationTests: XCTestCase { ) XCTAssertFalse(mockWebSocketConnectionManager.sentPayloads.isEmpty) XCTAssertEqual(try sentEventType(from: mockWebSocketConnectionManager.sentPayloads[0]), "conversation_initiation_client_data") - XCTAssertEqual(conversation.state, .active(.init(agentId: "test-agent-id"))) + XCTAssertEqual(conversation.state, .connected) let payload: [String: Any] = [ "type": "agent_response", @@ -232,52 +227,35 @@ final class ConversationTests: XCTestCase { XCTAssertEqual(conversation.messages.last?.role, .agent) } - func testTextOnlyStartDisconnectsPreviousActiveManagerBeforeSwitchingTransports() async throws { - mockWebRTCConnectionManager.room = Room() - mockWebRTCConnectionManager.onEventReceived = { _ in } - mockWebRTCConnectionManager.onDisconnected = {} - mockWebRTCConnectionManager.onRemoteSpeakingChanged = { _ in } - conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) - conversation._testing_setState(.ended(reason: .userEnded)) - - let options = makeConfig(configure: { options in - options.conversationOverrides = ConversationOverrides(textOnly: true) - }) - - try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: options + func testTextOnlyPublicAgentURLIncludesEnvironment() throws { + let url = try WebSocketConnectionManager.url( + for: .publicAgent(id: "test-agent-id"), + base: ElevenLabsEndpoints.production.textWebSocket, + environment: "staging" ) - XCTAssertEqual(mockWebRTCConnectionManager.disconnectCallCount, 1) - XCTAssertNil(mockWebRTCConnectionManager.onEventReceived) - XCTAssertNil(mockWebRTCConnectionManager.onDisconnected) - XCTAssertNil(mockWebRTCConnectionManager.onRemoteSpeakingChanged) - XCTAssertEqual(mockWebSocketConnectionManager.connectCallCount, 1) - XCTAssertEqual(conversation.state, .active(.init(agentId: "test-agent-id"))) + XCTAssertEqual(url.queryItems["agent_id"], "test-agent-id") + XCTAssertEqual(url.queryItems["environment"], "staging") } func testStartTextOnlySignedURLUsesProvidedWebSocketURL() async throws { let signedURL = "wss://api.elevenlabs.io/v1/convai/conversation?agent_id=agent-private&conversation_signature=sig" - let options = makeConfig(configure: { options in - options.conversationOverrides = ConversationOverrides(textOnly: true) - }) + conversation = makeConversation(config: makeConfig(textOnly: true)) try await conversation.startConversation( - auth: .signedWebSocketURL(signedURL), - options: options + auth: try .signedWebSocketURL(signedURL) ) XCTAssertEqual(mockWebRTCConnectionManager.connectCallCount, 0) XCTAssertEqual(mockWebSocketConnectionManager.connectCallCount, 1) XCTAssertEqual(mockWebSocketConnectionManager.lastConnectedURL?.absoluteString, signedURL) XCTAssertFalse(mockWebSocketConnectionManager.sentPayloads.isEmpty) - XCTAssertEqual(conversation.state, .active(.init(agentId: "agent-private"))) + XCTAssertEqual(conversation.state, .connected) } func testSignedWebSocketURLRejectsURLWithoutAgentId() { let urlMissingAgent = "wss://api.elevenlabs.io/v1/convai/conversation?conversation_signature=sig" - XCTAssertThrowsError(try ElevenLabsConfiguration.signedWebSocketURL(urlMissingAgent)) { error in + XCTAssertThrowsError(try ConversationAuth.signedWebSocketURL(urlMissingAgent)) { error in guard let convError = error as? ConversationError, case .authenticationFailed = convError else { @@ -287,14 +265,11 @@ final class ConversationTests: XCTestCase { } func testStartTextOnlyRejectsConversationTokenAuth() async throws { - let options = makeConfig(configure: { options in - options.conversationOverrides = ConversationOverrides(textOnly: true) - }) + conversation = makeConversation(config: makeConfig(textOnly: true)) do { try await conversation.startConversation( - auth: .conversationToken("livekit-token"), - options: options + auth: .conversationToken("livekit-token") ) XCTFail("Expected text-only startup to reject LiveKit token auth") } catch let error as ConversationError { @@ -318,27 +293,19 @@ final class ConversationTests: XCTestCase { } @MainActor - func testToggleMuteWhenNotConnected() async { - do { - try await conversation.toggleMute() - XCTFail("Should throw error when not connected") - } catch let error as ConversationError { - XCTAssertEqual(error, .notConnected) - } catch { - XCTFail("Unexpected error type") - } + func testToggleMuteWhenNotConnected() async throws { + // Lenient when not connected: a best-effort no-op (no throw, no state + // change), mirroring the agent-mute controls. + let before = conversation.isMicMuted + try await conversation.toggleMicMute() + XCTAssertEqual(conversation.isMicMuted, before, "Mic mute must be a no-op with no live session") } @MainActor - func testSetMutedWhenNotConnected() async { - do { - try await conversation.setMuted(true) - XCTFail("Should throw error when not connected") - } catch let error as ConversationError { - XCTAssertEqual(error, .notConnected) - } catch { - XCTFail("Unexpected error type") - } + func testSetMutedWhenNotConnected() async throws { + let before = conversation.isMicMuted + try await conversation.setMicMuted(!before) + XCTAssertEqual(conversation.isMicMuted, before, "Mic mute must be a no-op with no live session") } @MainActor @@ -346,12 +313,12 @@ final class ConversationTests: XCTestCase { mockWebRTCConnectionManager.room = Room() mockWebRTCConnectionManager.isMicrophoneMuted = false conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) - conversation._testing_setState(.active(.init(agentId: "test-agent"))) + conversation._testing_setState(.connected) - try await conversation.setMicrophoneMuted(true) + try await conversation.setHardwareMicMuted(true) XCTAssertTrue(mockWebRTCConnectionManager.isMicrophoneMuted) - XCTAssertTrue(conversation.isMuted) + XCTAssertTrue(conversation.isMicMuted) } @MainActor @@ -391,27 +358,23 @@ final class ConversationTests: XCTestCase { } func testStartConversationTokenFailure() async { - mockWebRTCConnectionManager.tokenError = .authenticationFailed("Mock authentication failed") - - let options = makeConfig() + mockWebRTCConnectionManager.tokenError = ConversationError.authenticationFailed("Mock authentication failed") guard let conversation else { return } await XCTAssertThrowsErrorAsync { try await conversation.startConversation( auth: .publicAgent(id: "test-agent"), - options: options + config: self.makeConfig() ) } errorHandler: { error in XCTAssertEqual(error as? ConversationError, .authenticationFailed("Mock authentication failed")) } - guard case let .failed(.token(conversationError), metrics) = conversation.startupState else { + guard case let .startupFailed(.token(conversationError)) = conversation.state else { return XCTFail("Expected startup failure due to token") } XCTAssertEqual(conversationError, .authenticationFailed("Mock authentication failed")) - XCTAssertEqual(conversation.state, .idle) - XCTAssertEqual(conversation.startupMetrics?.tokenFetch, metrics.tokenFetch) let errorsAfterTokenFailure = await capturedErrors.values(waitingFor: 1) XCTAssertEqual(errorsAfterTokenFailure, [.authenticationFailed("Mock authentication failed")]) } @@ -419,45 +382,39 @@ final class ConversationTests: XCTestCase { func testStartConversationConnectionFailure() async { mockWebRTCConnectionManager.shouldFailConnection = true - let options = makeConfig() - guard let conversation else { return } await XCTAssertThrowsErrorAsync { try await conversation.startConversation( auth: .publicAgent(id: "test-agent"), - options: options + config: self.makeConfig() ) } errorHandler: { error in XCTAssertEqual(error as? ConversationError, .connectionFailed("Mock connection failed")) } - guard case let .failed(.room(conversationError), metrics) = conversation.startupState else { + guard case let .startupFailed(.room(conversationError)) = conversation.state else { return XCTFail("Expected startup failure due to room connect") } XCTAssertEqual(conversationError, .connectionFailed("Mock connection failed")) - XCTAssertEqual(conversation.state, .idle) - XCTAssertEqual(conversation.startupMetrics?.roomConnect, metrics.roomConnect) let errorsAfterConnectionFailure = await capturedErrors.values(waitingFor: 1) XCTAssertEqual(errorsAfterConnectionFailure, [.connectionFailed("Mock connection failed")]) } func testStartConversationAgentTimeoutFailure() async { - let options = ConversationStartupConfiguration(agentReadyTimeout: 0.05) - - let conversationConfig = makeConfig(startupConfiguration: options) + let config = makeConfig(agentReadyTimeout: 0.05) + conversation = makeConversation(config: config) let startTask = Task { guard let conversation = self.conversation else { return } try await conversation.startConversation( - auth: .publicAgent(id: "test-agent"), - options: conversationConfig + auth: .publicAgent(id: "test-agent") ) } await Task.yield() - try? await conversation.setMuted(false) - XCTAssertFalse(conversation.isMuted) + try? await conversation.setMicMuted(false) + XCTAssertFalse(conversation.isMicMuted) mockWebRTCConnectionManager.timeoutAgentReady() await XCTAssertThrowsErrorAsync { @@ -466,31 +423,58 @@ final class ConversationTests: XCTestCase { XCTAssertEqual(error as? ConversationError, .agentTimeout) } - guard case .failed(.agentTimeout, _) = conversation.startupState else { + guard case .startupFailed(.agentTimeout) = conversation.state else { return XCTFail("Expected agent timeout failure state") } - XCTAssertEqual(conversation.state, .idle) - XCTAssertTrue(conversation.isMuted) + XCTAssertTrue(conversation.isMicMuted) XCTAssertNil(mockWebRTCConnectionManager.room) XCTAssertNil(mockWebRTCConnectionManager.onDisconnected) XCTAssertNil(mockWebRTCConnectionManager.onEventReceived) XCTAssertNil(mockWebRTCConnectionManager.onRemoteSpeakingChanged) - XCTAssertNil(mockWebRTCConnectionManager.errorHandler) let errorsAfterAgentTimeout = await capturedErrors.values(waitingFor: 1) XCTAssertEqual(errorsAfterAgentTimeout, [.agentTimeout]) } + func testStartConversationInitializationTimeoutFailure() async { + let config = makeConfig(agentReadyTimeout: 0.05) + // Agent joins the room, but `conversation_initiation_metadata` never + // arrives โ€” the init-handshake wait must time out as `.initializationTimeout`, + // distinct from the room-join `.agentTimeout`. + mockWebRTCConnectionManager.autoDeliverMetadata = false + conversation = makeConversation(config: config) + + let startTask = Task { + guard let conversation = self.conversation else { return } + try await conversation.startConversation( + auth: .publicAgent(id: "test-agent") + ) + } + + await Task.yield() + mockWebRTCConnectionManager.succeedAgentReady() + + await XCTAssertThrowsErrorAsync { + try await startTask.value + } errorHandler: { error in + XCTAssertEqual(error as? ConversationError, .initializationTimeout) + } + + guard case .startupFailed(.conversationInit(.initializationTimeout)) = conversation.state else { + return XCTFail("Expected initialization timeout failure state") + } + let errorsAfterInitTimeout = await capturedErrors.values(waitingFor: 1) + XCTAssertEqual(errorsAfterInitTimeout, [.initializationTimeout]) + } + func testStartConversationConversationInitFailure() async { mockWebRTCConnectionManager.publishError = ConversationError.connectionFailed("Publish failed") - let options = makeConfig() - guard let conversation else { return } let startTask = Task { try await conversation.startConversation( auth: .publicAgent(id: "test-agent"), - options: options + config: self.makeConfig() ) } @@ -504,7 +488,7 @@ final class ConversationTests: XCTestCase { XCTAssertEqual(error as? ConversationError, .connectionFailed("Publish failed")) } - guard case let .failed(.conversationInit(conversationError), _) = conversation.startupState else { + guard case let .startupFailed(.conversationInit(conversationError)) = conversation.state else { return XCTFail("Expected conversation init failure state") } @@ -513,30 +497,140 @@ final class ConversationTests: XCTestCase { XCTAssertNil(mockWebRTCConnectionManager.onDisconnected) XCTAssertNil(mockWebRTCConnectionManager.onEventReceived) XCTAssertNil(mockWebRTCConnectionManager.onRemoteSpeakingChanged) - XCTAssertNil(mockWebRTCConnectionManager.errorHandler) let errorsAfterInitFailure = await capturedErrors.values(waitingFor: 1) XCTAssertEqual(errorsAfterInitFailure, [.connectionFailed("Publish failed")]) } - func testAgentResponseCallbackTogglesFeedbackAvailability() async throws { + /// Ending while the agent-join wait is in flight must win: the session ends + /// as `.userEnded` and the spurious transport `agentTimeout` (produced when + /// `disconnect` releases the wait) is suppressed โ€” no `.startupFailed`, no + /// `onError`, and the in-flight `start` unwinds as cancellation. + func testEndDuringWaitingForAgentDoesNotReportFailure() async { + guard let conversation else { return } + + let startTask = Task { + try await conversation.startConversation( + auth: .publicAgent(id: "test-agent"), + config: self.makeConfig() + ) + } + + // Let startup progress until the manager is blocked waiting for the agent. + for _ in 0 ..< 100 where mockWebRTCConnectionManager.lastWaitTimeout == 0 { + await Task.yield() + } + XCTAssertGreaterThan(mockWebRTCConnectionManager.lastWaitTimeout, 0) + XCTAssertTrue(conversation.state.isConnecting) + + await conversation.endConversation() + + await XCTAssertThrowsErrorAsync { + try await startTask.value + } errorHandler: { error in + XCTAssertTrue(error is CancellationError, "Expected cancellation, got \(error)") + } + + XCTAssertEqual(conversation.state, .ended(reason: .userEnded)) + // Give any (erroneous) onError dispatch a chance to land, then assert none did. + for _ in 0 ..< 10 { await Task.yield() } + let errors = await capturedErrors.values() + XCTAssertTrue(errors.isEmpty, "User-initiated end must not fire onError, got \(errors)") + } + + /// Same guarantee for the later startup phase: ending while blocked on the + /// `conversation_initiation_metadata` handshake ends as `.userEnded` and + /// unwinds `start` as cancellation rather than a timeout failure. + func testEndDuringWaitingForInitDataDoesNotReportFailure() async { + guard let conversation else { return } + // Never deliver metadata, so startup blocks in `.waitingForInitData`. + mockWebRTCConnectionManager.autoDeliverMetadata = false + + let startTask = Task { + try await conversation.startConversation( + auth: .publicAgent(id: "test-agent"), + config: self.makeConfig() + ) + } + + await Task.yield() + mockWebRTCConnectionManager.succeedAgentReady() + + for _ in 0 ..< 100 where conversation.state != .connecting(phase: .waitingForInitData) { + await Task.yield() + } + XCTAssertEqual(conversation.state, .connecting(phase: .waitingForInitData)) + + await conversation.endConversation() + + await XCTAssertThrowsErrorAsync { + try await startTask.value + } errorHandler: { error in + XCTAssertTrue(error is CancellationError, "Expected cancellation, got \(error)") + } + + XCTAssertEqual(conversation.state, .ended(reason: .userEnded)) + for _ in 0 ..< 10 { await Task.yield() } + let errors = await capturedErrors.values() + XCTAssertTrue(errors.isEmpty, "User-initiated end must not fire onError, got \(errors)") + } + + /// Cancelling the start task itself (rather than calling `endConversation`) + /// while blocked on the `conversation_initiation_metadata` handshake must + /// break the wait promptly, unwind `start` as `CancellationError` instead of + /// a spurious init timeout, reset to `.idle`, and not fire `onError`. + func testCancellingStartTaskDuringInitWaitUnwindsAsCancellation() async { + guard let conversation else { return } + // Never deliver metadata, so startup blocks in `.waitingForInitData`. + mockWebRTCConnectionManager.autoDeliverMetadata = false + + let startTask = Task { + try await conversation.startConversation( + auth: .publicAgent(id: "test-agent"), + // Long init timeout so the cancel, not the timeout, ends the wait. + config: self.makeConfig(agentReadyTimeout: 30.0) + ) + } + + // Startup pre-warms a real audio engine, which needs wall-clock time, so + // poll with short sleeps (not bare yields) until it blocks on the agent + // gate, then release that gate so it advances to the init handshake. + for _ in 0 ..< 200 where mockWebRTCConnectionManager.lastWaitTimeout == 0 { + try? await Task.sleep(nanoseconds: 5_000_000) + } + mockWebRTCConnectionManager.succeedAgentReady() + + for _ in 0 ..< 200 where conversation.state != .connecting(phase: .waitingForInitData) { + try? await Task.sleep(nanoseconds: 5_000_000) + } + XCTAssertEqual(conversation.state, .connecting(phase: .waitingForInitData)) + + startTask.cancel() + + await XCTAssertThrowsErrorAsync { + try await startTask.value + } errorHandler: { error in + XCTAssertTrue(error is CancellationError, "Expected cancellation, got \(error)") + } + + XCTAssertEqual(conversation.state, .idle) + for _ in 0 ..< 10 { await Task.yield() } + let errors = await capturedErrors.values() + XCTAssertTrue(errors.isEmpty, "Cancellation must not fire onError, got \(errors)") + } + + func testAgentResponseCallbackAndSendFeedbackWhileConnected() async throws { let receivedResponses = ValueRecorder<(String, Int)>() - let feedbackStates = ValueRecorder() - let options = makeConfig(configure: { options in - options.onAgentResponse = { text, eventId in + let conversation = makeConversation(callbacks: makeCallbacks(configure: { callbacks in + callbacks.onAgentResponse = { text, eventId in Task { await receivedResponses.append((text, eventId)) } } - options.onCanSendFeedbackChange = { canSend in - Task { await feedbackStates.append(canSend) } - } - }) - - let conversation = Conversation(dependencyProvider: dependencyProvider, options: options) + })) // Set up mock connection manager with a room and active state so sendFeedback can publish mockWebRTCConnectionManager.room = Room() conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) - conversation._testing_setState(ConversationState.active(.init(agentId: "test"))) + conversation._testing_setState(.connected) await conversation._testing_handleIncomingEvent( IncomingEvent.agentResponse(AgentResponseEvent(response: "Hello", eventId: 42)) @@ -549,27 +643,19 @@ final class ConversationTests: XCTestCase { XCTAssertEqual(responsesSnapshot.count, 1) XCTAssertEqual(responsesSnapshot.first?.0, "Hello") XCTAssertEqual(responsesSnapshot.first?.1, 42) - let initialFeedbackState = await feedbackStates.last() - XCTAssertEqual(initialFeedbackState, true) + // Feedback for a valid agent event id while connected simply succeeds; the + // SDK no longer tracks availability (see ConversationClient.sendFeedback). try await conversation.sendFeedback(FeedbackEvent.Score.like, eventId: 42) - - // Allow async callbacks to complete - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - - let updatedFeedbackState = await feedbackStates.last() - XCTAssertEqual(updatedFeedbackState, false) } func testVadScoreCallbackReceivesScores() async { let vadScores = ValueRecorder() - let options = makeConfig(configure: { options in - options.onVadScore = { score in + let conversation = makeConversation(callbacks: makeCallbacks(configure: { callbacks in + callbacks.onVadScore = { score in Task { await vadScores.append(score) } } - }) - - let conversation = Conversation(dependencyProvider: dependencyProvider, options: options) + })) await conversation._testing_handleIncomingEvent(IncomingEvent.vadScore(VadScoreEvent(vadScore: 0.87))) // Allow async callbacks to complete @@ -581,13 +667,11 @@ final class ConversationTests: XCTestCase { func testAgentToolResponseCallbackReceivesEvent() async { let capturedToolNames = ValueRecorder() - let options = makeConfig(configure: { options in - options.onAgentToolResponse = { (event: AgentToolResponseEvent) in + let conversation = makeConversation(callbacks: makeCallbacks(configure: { callbacks in + callbacks.onAgentToolResponse = { (event: AgentToolResponseEvent) in Task { await capturedToolNames.append(event.toolName) } } - }) - - let conversation = Conversation(dependencyProvider: dependencyProvider, options: options) + })) let toolEvent = AgentToolResponseEvent(toolName: "end_call", toolCallId: "id", toolType: "action", isError: false, eventId: 10) await conversation._testing_handleIncomingEvent(IncomingEvent.agentToolResponse(toolEvent)) @@ -599,20 +683,14 @@ final class ConversationTests: XCTestCase { XCTAssertEqual(toolNames, ["end_call"]) } - func testInterruptionCallbackDisablesFeedback() async { + func testInterruptionCallbackReceivesEvent() async { let interruptionIds = ValueRecorder() - let feedbackStates = ValueRecorder() - let options = makeConfig(configure: { options in - options.onInterruption = { id in + let conversation = makeConversation(callbacks: makeCallbacks(configure: { callbacks in + callbacks.onInterruption = { id in Task { await interruptionIds.append(id) } } - options.onCanSendFeedbackChange = { canSend in - Task { await feedbackStates.append(canSend) } - } - }) - - let conversation = Conversation(dependencyProvider: dependencyProvider, options: options) + })) await conversation._testing_handleIncomingEvent(IncomingEvent.interruption(InterruptionEvent(eventId: 7))) // Allow async callbacks to complete @@ -620,8 +698,38 @@ final class ConversationTests: XCTestCase { let interruptionSnapshot = await interruptionIds.values() XCTAssertEqual(interruptionSnapshot, [7]) - let interruptionFeedbackState = await feedbackStates.last() - XCTAssertEqual(interruptionFeedbackState, false) + } + + func testPingSurfacesLatencyAndRespondsWithPong() async throws { + let pingLatencies = ValueRecorder() + let conversation = makeConversation(callbacks: makeCallbacks(configure: { callbacks in + callbacks.onPing = { ms in + Task { await pingLatencies.append(ms) } + } + })) + + mockWebRTCConnectionManager.room = Room() + conversation._testing_setWebRTCConnectionManager(mockWebRTCConnectionManager) + conversation._testing_setState(.connected) + + await conversation._testing_handleIncomingEvent( + IncomingEvent.ping(PingEvent(eventId: 99, pingMs: 42)) + ) + + // The pong is dispatched off the event-handling loop, so give the + // detached send a chance to land. + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + + let latencies = await pingLatencies.values() + XCTAssertEqual(latencies, [42]) + + let pong = try XCTUnwrap( + mockWebRTCConnectionManager.publishedPayloads.compactMap { + try? JSONSerialization.jsonObject(with: $0) as? [String: Any] + }.first { $0["type"] as? String == "pong" }, + "Expected a pong to be published in response to the ping" + ) + XCTAssertEqual(pong["event_id"] as? Int, 99) } @MainActor @@ -654,14 +762,45 @@ final class ConversationTests: XCTestCase { XCTAssertNotEqual(ConversationError.notConnected, ConversationError.alreadyActive) } + func testConnectionFailedPreservesUnderlyingError() { + let urlError = URLError(.notConnectedToInternet) + let wrapped = ConversationError.connectionFailed(urlError) + + // The original error is recoverable and downcastable to its concrete type. + XCTAssertEqual((wrapped.underlyingError as? URLError)?.code, .notConnectedToInternet) + // The localized description is still surfaced for display. + XCTAssertEqual(wrapped.errorDescription, "Connection failed: \(urlError.localizedDescription)") + + // Same for the mic-toggle wrapper. + let micWrapped = ConversationError.microphoneToggleFailed(urlError) + XCTAssertEqual((micWrapped.underlyingError as? URLError)?.code, .notConnectedToInternet) + + // Non-wrapped cases (and string-built ones) carry no underlying error. + XCTAssertNil(ConversationError.connectionFailed("plain").underlyingError) + XCTAssertNil(ConversationError.notConnected.underlyingError) + } + + func testConnectionFailedEqualityIgnoresUnderlyingError() { + // Two wrapped errors with the same description compare equal โ€” the boxed + // underlying error isn't compared by identity, so equality stays driven by + // the description string. + let a = ConversationError.connectionFailed(URLError(.timedOut)) + let b = ConversationError.connectionFailed(URLError(.timedOut)) + XCTAssertEqual(a, b) + } + func testConversationStateEnum() { let idleState: ConversationState = .idle - let connectingState: ConversationState = .connecting - let activeState: ConversationState = .active(CallInfo(agentId: "test")) + let connectingState: ConversationState = .connecting(phase: .authorizing) + let connectedState: ConversationState = .connected XCTAssertNotEqual(idleState, connectingState) - XCTAssertNotEqual(connectingState, activeState) - XCTAssertNotEqual(idleState, activeState) + XCTAssertNotEqual(connectingState, connectedState) + XCTAssertNotEqual(idleState, connectedState) + XCTAssertNotEqual( + ConversationState.connecting(phase: .authorizing), + ConversationState.connecting(phase: .connecting) + ) } func testFeedbackTypeEnum() { @@ -672,23 +811,23 @@ final class ConversationTests: XCTestCase { func testAgentDisconnectEndsConversation() async throws { let disconnectReasons = ValueRecorder() - let options = makeConfig(configure: { options in - options.onDisconnect = { reason in + conversation = makeConversation(callbacks: makeCallbacks(configure: { callbacks in + callbacks.onDisconnect = { reason in Task { await disconnectReasons.append(reason) } } - }) + })) let startTask = Task { guard let conversation = self.conversation else { return } try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "test-agent-id"), - options: options + auth: .publicAgent(id: "test-agent-id"), + config: makeConfig() ) } await Task.yield() mockWebRTCConnectionManager.succeedAgentReady() try await startTask.value - XCTAssertEqual(conversation.state, .active(.init(agentId: "test-agent-id"))) + XCTAssertEqual(conversation.state, .connected) // capture call counts before the disconnect event. let disconnectsBefore = mockWebRTCConnectionManager.disconnectCallCount // Simulate agent disconnect @@ -767,21 +906,36 @@ actor ValueRecorder { extension ConversationTests { private func makeConfig( - startupConfiguration: ConversationStartupConfiguration = .default, - onStartupStateChange: (@Sendable (ConversationStartupState) -> Void)? = nil, - configure: ((inout ConversationOptions) -> Void)? = nil - ) -> ConversationOptions { - var options = ConversationOptions( - onStartupStateChange: onStartupStateChange, - startupConfiguration: startupConfiguration + agentReadyTimeout: TimeInterval = 3.0, + textOnly: Bool = false + ) -> ConversationConfig { + ConversationConfig( + textOnly: textOnly, + agentJoinTimeout: agentReadyTimeout, + conversationInitTimeout: agentReadyTimeout ) + } - options.onError = { [capturedErrors] error in + private func makeCallbacks( + configure: ((inout ConversationCallbacks) -> Void)? = nil + ) -> ConversationCallbacks { + var callbacks = ConversationCallbacks() + callbacks.onError = { [capturedErrors] error in Task { await capturedErrors.append(error) } } + configure?(&callbacks) + return callbacks + } - configure?(&options) - return options + private func makeConversation( + config: ConversationConfig = .default, + callbacks: ConversationCallbacks? = nil + ) -> Conversation { + Conversation( + dependencyProvider: dependencyProvider, + config: config, + callbacks: callbacks ?? makeCallbacks() + ) } } diff --git a/Tests/ElevenLabsTests/Unit/ElevenLabsSDKTests.swift b/Tests/ElevenLabsTests/Unit/ElevenLabsSDKTests.swift index 97e398d6..d876e224 100644 --- a/Tests/ElevenLabsTests/Unit/ElevenLabsSDKTests.swift +++ b/Tests/ElevenLabsTests/Unit/ElevenLabsSDKTests.swift @@ -3,55 +3,53 @@ import XCTest final class ElevenLabsSDKTests: XCTestCase { func testSDKVersionExists() { - XCTAssertEqual(ElevenLabs.version, "3.2.2") - XCTAssertFalse(ElevenLabs.version.isEmpty) + XCTAssertEqual(SDKVersion.version, "3.2.2") + XCTAssertFalse(SDKVersion.version.isEmpty) } - func testDefaultConfiguration() { - let config = ElevenLabs.Configuration.default + func testDefaultEndpoints() { + let endpoints = ElevenLabsEndpoints.production - XCTAssertNil(config.apiEndpoint) - XCTAssertEqual(config.logLevel, .warning) - XCTAssertFalse(config.debugMode) + XCTAssertEqual(endpoints.voiceWebSocket.absoluteString, "wss://livekit.rtc.elevenlabs.io") + XCTAssertEqual(endpoints.textWebSocket.absoluteString, "wss://api.elevenlabs.io/v1/convai/conversation") + XCTAssertEqual(endpoints.apiBase.absoluteString, "https://api.elevenlabs.io") } - func testCustomConfiguration() { - let config = ElevenLabs.Configuration( - apiEndpoint: URL(string: "https://custom.api.com"), - logLevel: .debug, - debugMode: true + func testCustomEndpointsOverrideSingleField() { + let endpoints = ElevenLabsEndpoints( + voiceWebSocket: URL(string: "wss://livekit.custom.example.com")! ) - XCTAssertEqual(config.apiEndpoint, URL(string: "https://custom.api.com")) - XCTAssertEqual(config.logLevel, .debug) - XCTAssertTrue(config.debugMode) + // Overridden field is applied; the rest fall back to production. + XCTAssertEqual(endpoints.voiceWebSocket.absoluteString, "wss://livekit.custom.example.com") + XCTAssertEqual(endpoints.apiBase, ElevenLabsEndpoints.production.apiBase) } - @MainActor - func testConfigureSDK() { - let config = ElevenLabs.Configuration( - apiEndpoint: URL(string: "https://test.api.com"), - logLevel: .info, - debugMode: false - ) + func testApiBaseConvenienceDerivesEndpoints() { + let endpoints = ElevenLabsEndpoints.apiBase(URL(string: "https://my-proxy.example.com")!) - ElevenLabs.configure(config) + XCTAssertEqual(endpoints.apiBase.absoluteString, "https://my-proxy.example.com") + // Scheme is upgraded to wss for the text endpoint. + XCTAssertEqual(endpoints.textWebSocket.absoluteString, "wss://my-proxy.example.com/v1/convai/conversation") + // LiveKit stays on the production host unless overridden. + XCTAssertEqual(endpoints.voiceWebSocket, ElevenLabsEndpoints.production.voiceWebSocket) + } - // Verify configuration was applied (in real implementation) - // This would require exposing the internal configuration for testing + func testLogLevelDefaultsToWarningAndIsConfigurable() { + XCTAssertEqual(ConversationConfig().logLevel, .warning) + + let config = ConversationConfig(logLevel: .trace) + XCTAssertEqual(config.logLevel, .trace) } + @MainActor func testStartConversationWithAgentId() async { - let config = ConversationConfig() + let client = ConversationClient() do { - let conversation = try await ElevenLabs.startConversation( - agentId: "test-agent-123", - config: config - ) - - XCTAssertNotNil(conversation) + try await client.start(auth: .publicAgent(id: "test-agent-123"), config: ConversationConfig()) // In a proper test environment with mocks, we'd verify connection + XCTAssertEqual(client.state, .connected) } catch { // Expected to fail without proper API setup XCTAssertTrue(error is ConversationError) @@ -59,7 +57,7 @@ final class ElevenLabsSDKTests: XCTestCase { } func testConversationTokenAuthConfiguration() { - let auth = ElevenLabsConfiguration.conversationToken("test-token-123") + let auth = ConversationAuth.conversationToken("test-token-123") switch auth.authSource { case let .conversationToken(token): XCTAssertEqual(token, "test-token-123") @@ -70,41 +68,24 @@ final class ElevenLabsSDKTests: XCTestCase { func testSignedWebSocketURLAuthConfiguration() throws { let url = "wss://api.elevenlabs.io/v1/convai/conversation?agent_id=agent-123&conversation_signature=sig" - let auth = try ElevenLabsConfiguration.signedWebSocketURL(url) + let auth = try ConversationAuth.signedWebSocketURL(url) switch auth.authSource { case let .signedWebSocketURL(signedURL, agentId): XCTAssertEqual(signedURL, url) XCTAssertEqual(agentId, "agent-123") - XCTAssertEqual(auth.agentId, "agent-123") default: XCTFail("Expected signedWebSocketURL case") } } - func testCustomTokenProviderAuthConfiguration() { - let tokenProvider: @Sendable () async throws -> String = { - "dynamic-token-123" - } - let auth = ElevenLabsConfiguration.customTokenProvider(tokenProvider) - switch auth.authSource { - case .customTokenProvider: - break // Success - provider is configured - default: - XCTFail("Expected customTokenProvider case") - } - } - - func testConfigurationLogLevels() { - let debugConfig = ElevenLabs.Configuration(logLevel: .debug) - let infoConfig = ElevenLabs.Configuration(logLevel: .info) - let warningConfig = ElevenLabs.Configuration(logLevel: .warning) - let errorConfig = ElevenLabs.Configuration(logLevel: .error) + - XCTAssertEqual(debugConfig.logLevel, .debug) - XCTAssertEqual(infoConfig.logLevel, .info) - XCTAssertEqual(warningConfig.logLevel, .warning) - XCTAssertEqual(errorConfig.logLevel, .error) + func testLogLevelOrdering() { + XCTAssertLessThan(LogLevel.error, .warning) + XCTAssertLessThan(LogLevel.warning, .info) + XCTAssertLessThan(LogLevel.info, .debug) + XCTAssertLessThan(LogLevel.debug, .trace) } func testConversationConfigDefaults() { @@ -112,15 +93,12 @@ final class ElevenLabsSDKTests: XCTestCase { XCTAssertNil(config.agentOverrides) XCTAssertNil(config.ttsOverrides) - XCTAssertNil(config.conversationOverrides) + XCTAssertFalse(config.textOnly) } func testAuthenticationMethods() { - let agentAuth = ElevenLabsConfiguration.publicAgent(id: "agent-123") - let tokenAuth = ElevenLabsConfiguration.conversationToken("token-456") - let providerAuth = ElevenLabsConfiguration.customTokenProvider { - "provided-token" - } + let agentAuth = ConversationAuth.publicAgent(id: "agent-123") + let tokenAuth = ConversationAuth.conversationToken("token-456") switch agentAuth.authSource { case let .publicAgentId(id): @@ -135,18 +113,11 @@ final class ElevenLabsSDKTests: XCTestCase { default: XCTFail("Expected conversationToken case") } - - switch providerAuth.authSource { - case .customTokenProvider: - break // Success - default: - XCTFail("Expected customTokenProvider case") - } } func testSDKModuleImports() { // Verify that all necessary types are accessible - XCTAssertNotNil(ElevenLabs.self) + XCTAssertNotNil(ConversationClient.self) XCTAssertNotNil(Conversation.self) XCTAssertNotNil(ConversationConfig.self) XCTAssertNotNil(ConversationError.self) diff --git a/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift b/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift index c95b0dd8..7a243474 100644 --- a/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift +++ b/Tests/ElevenLabsTests/Unit/ErrorHandlingIntegrationTests.swift @@ -1,5 +1,6 @@ // swiftlint:disable file_length type_body_length function_body_length @testable import ElevenLabs +import Combine import XCTest /// Integration tests for error handling scenarios with real ElevenLabs API @@ -18,16 +19,28 @@ final class ErrorHandlingIntegrationTests: XCTestCase { private let testAgentId = "agent_7601k95fk7q2eyfbp4bncp5znp6x" private var conversation: Conversation? + private var cancellables = Set() override func setUp() async throws { conversation = nil + cancellables.removeAll() } override func tearDown() async throws { + cancellables.removeAll() await conversation?.endConversation() conversation = nil } + // MARK: - Helpers + + private func makeConfig(agentReadyTimeout: TimeInterval = 3.0) -> ConversationConfig { + ConversationConfig( + agentJoinTimeout: agentReadyTimeout, + conversationInitTimeout: agentReadyTimeout + ) + } + // MARK: - Error Callback Tests /// Test that onError callback receives errors during connection failures @@ -35,11 +48,7 @@ final class ErrorHandlingIntegrationTests: XCTestCase { let errorExpectation = expectation(description: "Error callback should be called") let collector = ErrorCollector() - let options = ConversationOptions( - onStartupStateChange: { state in - print("๐Ÿ“Š Startup state: \(state)") - Task { await collector.addState(state) } - }, + let callbacks = ConversationCallbacks( onError: { error in print("โœ… onError callback received: \(error)") Task { await collector.addError(error) } @@ -47,17 +56,20 @@ final class ErrorHandlingIntegrationTests: XCTestCase { } ) - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) + let conversation = Conversation(callbacks: callbacks) self.conversation = conversation + conversation.$state + .sink { state in + print("๐Ÿ“Š State: \(state)") + Task { await collector.addState(state) } + } + .store(in: &cancellables) // Use an invalid agent ID to trigger an error do { try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "invalid_agent_id_12345"), - options: options + auth: .publicAgent(id: "invalid_agent_id_12345"), + config: makeConfig() ) XCTFail("Should have thrown an error") } catch { @@ -88,39 +100,30 @@ final class ErrorHandlingIntegrationTests: XCTestCase { let readyExpectation = expectation(description: "Agent ready callback should be called") let collector = ErrorCollector() - // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) // Increased from default 3.0 - - // Use automatic network strategy for faster test connections (allows all connection types) - let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) - - let options = ConversationOptions( + let callbacks = ConversationCallbacks( onAgentReady: { print("โœ… Agent ready!") readyExpectation.fulfill() }, - onStartupStateChange: { state in - print("๐Ÿ“Š Startup state: \(state)") - Task { await collector.addState(state) } - }, - startupConfiguration: startupConfig, - networkConfiguration: networkConfig, onError: { error in print("โŒ Unexpected error in success test: \(error)") Task { await collector.addError(error) } } ) - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) + let conversation = Conversation(callbacks: callbacks) self.conversation = conversation + conversation.$state + .sink { state in + print("๐Ÿ“Š State: \(state)") + Task { await collector.addState(state) } + } + .store(in: &cancellables) do { try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: testAgentId), - options: options + auth: .publicAgent(id: testAgentId), + config: makeConfig(agentReadyTimeout: 10.0) ) await fulfillment(of: [readyExpectation], timeout: 15.0) @@ -135,20 +138,15 @@ final class ErrorHandlingIntegrationTests: XCTestCase { XCTAssertTrue(capturedErrors.isEmpty, "Error array should be empty") // Verify connection is active - XCTAssertTrue(conversation.state.isActive, "Conversation should be active") + XCTAssertEqual(conversation.state, .connected, "Conversation should be connected") print("\n๐Ÿ“Š Startup states (\(capturedStartupStates.count)):") for (index, state) in capturedStartupStates.enumerated() { print(" \(index + 1). \(state)") } - if case let .active(_, metrics) = conversation.startupState { - print("\nโฑ๏ธ Startup metrics:") - print(" Total: \(String(format: "%.3f", metrics.total ?? 0))s") - print(" Token fetch: \(String(format: "%.3f", metrics.tokenFetch ?? 0))s") - print(" Room connect: \(String(format: "%.3f", metrics.roomConnect ?? 0))s") - print(" Agent ready: \(String(format: "%.3f", metrics.agentReady ?? 0))s") - print(" Init attempts: \(metrics.conversationInitAttempts)") + if case .connected = conversation.state { + print("\nโฑ๏ธ Startup complete (connected)") } // Clean disconnect @@ -164,33 +162,22 @@ final class ErrorHandlingIntegrationTests: XCTestCase { let readyExpectation = expectation(description: "Agent ready") let collector = ErrorCollector() - // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) - - // Use automatic network strategy for faster test connections - let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) - - let options = ConversationOptions( + let callbacks = ConversationCallbacks( onAgentReady: { readyExpectation.fulfill() }, - startupConfiguration: startupConfig, - networkConfiguration: networkConfig, onError: { error in print("โŒ Error reported: \(error)") Task { await collector.addError(error) } } ) - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) + let conversation = Conversation(callbacks: callbacks) self.conversation = conversation try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: testAgentId), - options: options + auth: .publicAgent(id: testAgentId), + config: makeConfig(agentReadyTimeout: 10.0) ) await fulfillment(of: [readyExpectation], timeout: 15.0) @@ -218,39 +205,28 @@ final class ErrorHandlingIntegrationTests: XCTestCase { func testRapidConnectionAttempts() async throws { print("\n๐Ÿงช Testing rapid connection attempts...") - // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) - - // Use automatic network strategy for faster test connections - let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) - for attempt in 1 ... 3 { print("\n--- Attempt \(attempt) ---") - let options = ConversationOptions( - onStartupStateChange: { state in - print(" ๐Ÿ“Š State: \(state)") - }, - startupConfiguration: startupConfig, - networkConfiguration: networkConfig, + let callbacks = ConversationCallbacks( onError: { error in print(" โŒ Error: \(error)") } ) - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) + let conversation = Conversation(callbacks: callbacks) + conversation.$state + .sink { state in print(" ๐Ÿ“Š State: \(state)") } + .store(in: &cancellables) do { try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: testAgentId), - options: options + auth: .publicAgent(id: testAgentId), + config: makeConfig(agentReadyTimeout: 10.0) ) print(" โœ… Connection \(attempt) successful") - XCTAssertTrue(conversation.state.isActive) + XCTAssertEqual(conversation.state, .connected) // Brief interaction try? await conversation.sendMessage("Test message \(attempt)") @@ -271,31 +247,30 @@ final class ErrorHandlingIntegrationTests: XCTestCase { let errorExpectation = expectation(description: "Should receive error state") let collector = ErrorCollector() - let options = ConversationOptions( - onStartupStateChange: { state in - print("๐Ÿ“Š State transition: \(state)") - Task { await collector.addState(state) } - - if case .failed = state { - errorExpectation.fulfill() - } - }, + let callbacks = ConversationCallbacks( onError: { error in print("โŒ Error: \(error)") } ) - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) + let conversation = Conversation(callbacks: callbacks) self.conversation = conversation + conversation.$state + .sink { state in + print("๐Ÿ“Š State transition: \(state)") + Task { await collector.addState(state) } + + if case .startupFailed = state { + errorExpectation.fulfill() + } + } + .store(in: &cancellables) // Use invalid agent to trigger failure do { try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: "invalid_agent"), - options: options + auth: .publicAgent(id: "invalid_agent"), + config: makeConfig() ) } catch { // Expected @@ -312,55 +287,14 @@ final class ErrorHandlingIntegrationTests: XCTestCase { // Verify we got expected state transitions XCTAssertTrue(capturedStartupStates.contains { state in - if case .resolvingToken = state { return true } + if case .connecting(.authorizing) = state { return true } return false - }, "Should have resolvingToken state") + }, "Should have authorizing state") XCTAssertTrue(capturedStartupStates.contains { state in - if case .failed = state { return true } + if case .startupFailed = state { return true } return false - }, "Should have failed state") - } - - /// Test custom token provider error handling - func testCustomTokenProviderError() async throws { - let errorExpectation = expectation(description: "Should receive error") - let collector = ErrorCollector() - - let options = ConversationOptions( - onError: { error in - print("โŒ Token provider error: \(error)") - Task { await collector.addError(error) } - errorExpectation.fulfill() - } - ) - - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) - self.conversation = conversation - - // Provide a token provider that throws an error - do { - try await conversation.startConversation( - auth: .customTokenProvider { - throw NSError(domain: "TestError", code: 500, userInfo: [ - NSLocalizedDescriptionKey: "Custom token provider failed" - ]) - }, - options: options - ) - XCTFail("Should have thrown error") - } catch { - print("โœ… Caught error: \(error)") - } - - await fulfillment(of: [errorExpectation], timeout: 5.0) - - let capturedErrors = await collector.errors - XCTAssertFalse(capturedErrors.isEmpty, "Error callback should have been invoked") - print("๐Ÿ“‹ Final captured error: \(capturedErrors.first?.errorDescription ?? "none")") + }, "Should have startup-failed state") } /// Test network permission error detection @@ -369,27 +303,24 @@ final class ErrorHandlingIntegrationTests: XCTestCase { print("โ„น๏ธ This test verifies error callbacks work for network issues") let collector = ErrorCollector() - let options = ConversationOptions( - onStartupStateChange: { state in - print("๐Ÿ“Š State: \(state)") - }, + let callbacks = ConversationCallbacks( onError: { error in print("โŒ Network error: \(error)") Task { await collector.addError(error) } } ) - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) + let conversation = Conversation(callbacks: callbacks) self.conversation = conversation + conversation.$state + .sink { state in print("๐Ÿ“Š State: \(state)") } + .store(in: &cancellables) // Attempt connection - may succeed or fail depending on network do { try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: testAgentId), - options: options + auth: .publicAgent(id: testAgentId), + config: makeConfig() ) print("โœ… Connection succeeded (network available)") await conversation.endConversation() @@ -422,13 +353,7 @@ extension ErrorHandlingIntegrationTests { // Use actor to safely capture errors from Sendable closures let errorCollector = ErrorCollector() - // Increase timeout for real network conditions - let startupConfig = ConversationStartupConfiguration(agentReadyTimeout: 10.0) - - // Use automatic network strategy for faster test connections - let networkConfig = LiveKitNetworkConfiguration(strategy: .automatic) - - let options = ConversationOptions( + let callbacks = ConversationCallbacks( onAgentReady: { let timestamp = Self.formatTimestamp() print("โœ… [\(timestamp)] AGENT READY") @@ -437,15 +362,6 @@ extension ErrorHandlingIntegrationTests { let timestamp = Self.formatTimestamp() print("๐Ÿ”Œ [\(timestamp)] DISCONNECTED (reason: \(reason))") }, - onStartupStateChange: { state in - let timestamp = Self.formatTimestamp() - print("๐Ÿ“Š [\(timestamp)] STARTUP STATE: \(state)") - Task { - await errorCollector.addState(state) - } - }, - startupConfiguration: startupConfig, - networkConfiguration: networkConfig, onError: { error in let timestamp = Self.formatTimestamp() print("\nโŒ [\(timestamp)] ERROR CALLBACK INVOKED:") @@ -458,18 +374,24 @@ extension ErrorHandlingIntegrationTests { } ) - let conversation = Conversation( - dependencyProvider: Dependencies(), - options: options - ) + let conversation = Conversation(callbacks: callbacks) self.conversation = conversation + conversation.$state + .sink { state in + let timestamp = Self.formatTimestamp() + print("๐Ÿ“Š [\(timestamp)] STATE: \(state)") + Task { + await errorCollector.addState(state) + } + } + .store(in: &cancellables) print("\n๐Ÿš€ Starting connection...") do { try await conversation.startConversation( - auth: ElevenLabsConfiguration.publicAgent(id: testAgentId), - options: options + auth: .publicAgent(id: testAgentId), + config: makeConfig(agentReadyTimeout: 10.0) ) print("\nโœ… CONNECTION SUCCESSFUL") @@ -528,13 +450,13 @@ extension ErrorHandlingIntegrationTests { /// Actor to safely collect errors from Sendable closures private actor ErrorCollector { var errors: [ConversationError] = [] - var states: [ConversationStartupState] = [] + var states: [ConversationState] = [] func addError(_ error: ConversationError) { errors.append(error) } - func addState(_ state: ConversationStartupState) { + func addState(_ state: ConversationState) { states.append(state) } } diff --git a/Tests/ElevenLabsTests/Unit/LiveKitReadinessDelegateTests.swift b/Tests/ElevenLabsTests/Unit/LiveKitReadinessDelegateTests.swift index 7d5e3505..149d32b9 100644 --- a/Tests/ElevenLabsTests/Unit/LiveKitReadinessDelegateTests.swift +++ b/Tests/ElevenLabsTests/Unit/LiveKitReadinessDelegateTests.swift @@ -7,7 +7,7 @@ import XCTest @MainActor final class LiveKitReadinessDelegateTests: XCTestCase { private func makeDelegate() -> RoomDelegate { - LiveKitReadinessDelegate(logger: SDKLogger(logLevel: .warning)) + LiveKitReadinessDelegate(logger: SDKLogger(levelOverride: .warning)) } func testImplementsGenuineRoomDelegateMethods() {