From b4da9265d77be02772766e0df23defb7e8719626 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 27 Apr 2026 11:32:53 -0400 Subject: [PATCH 1/2] VAPI-2990 Add connect status callbacks for Connect verb Surface connectStatus events from the gateway so SDK consumers can react to the terminal outcome of a verb (completed, timed out, denied, canceled, failed). Extends ReadyMetadata with connect status fields and adds a dedicated onConnectStatus callback. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/BandwidthRTC/BandwidthRTC.swift | 19 ++++- .../BandwidthRTC/Types/ConnectStatus.swift | 11 +++ .../BandwidthRTC/Types/ReadyMetadata.swift | 34 +++++++- .../BandwidthRTCTests/BandwidthRTCTests.swift | 85 +++++++++++++++++++ VERSION | 2 +- 5 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 Sources/BandwidthRTC/Types/ConnectStatus.swift diff --git a/Sources/BandwidthRTC/BandwidthRTC.swift b/Sources/BandwidthRTC/BandwidthRTC.swift index f08b9d6..975e48a 100644 --- a/Sources/BandwidthRTC/BandwidthRTC.swift +++ b/Sources/BandwidthRTC/BandwidthRTC.swift @@ -28,6 +28,9 @@ public final class BandwidthRTCClient: @unchecked Sendable { /// Called when the BRTC platform signals readiness. public var onReady: (@Sendable (ReadyMetadata) -> Void)? + /// Called when the gateway reports a connect status update (terminal outcome of a `` verb). + public var onConnectStatus: (@Sendable (ReadyMetadata) -> Void)? + /// Called when the remote side disconnects (subscribe ICE disconnected/failed). public var onRemoteDisconnected: (@Sendable () -> Void)? @@ -340,9 +343,19 @@ public final class BandwidthRTCClient: @unchecked Sendable { self.onReady?(metadata) } - // Handle established event - await signaling.onEvent("established") { _ in - Logger.shared.debug("Connection established") + // Handle connect status event (terminal outcome of a verb) + await signaling.onEvent("connectStatus") { [weak self] data in + guard let self else { return } + + let metadata: ReadyMetadata + if data.isEmpty { + metadata = ReadyMetadata() + } else { + metadata = (try? JSONDecoder().decode(ReadyMetadata.self, from: data)) ?? ReadyMetadata() + } + + Logger.shared.debug("Connect status event: status=\(metadata.connectStatus.map(String.init(describing:)) ?? "nil")") + self.onConnectStatus?(metadata) } // Handle disconnect diff --git a/Sources/BandwidthRTC/Types/ConnectStatus.swift b/Sources/BandwidthRTC/Types/ConnectStatus.swift new file mode 100644 index 0000000..e36eaf1 --- /dev/null +++ b/Sources/BandwidthRTC/Types/ConnectStatus.swift @@ -0,0 +1,11 @@ +import Foundation + +/// The terminal outcome of a `` verb execution. +public enum ConnectStatus: String, Decodable, Sendable { + case initiated = "INITIATED" + case completed = "COMPLETED" + case timedOut = "TIMED_OUT" + case denied = "DENIED" + case canceled = "CANCELED" + case failed = "FAILED" +} diff --git a/Sources/BandwidthRTC/Types/ReadyMetadata.swift b/Sources/BandwidthRTC/Types/ReadyMetadata.swift index 9269e37..45ec54d 100644 --- a/Sources/BandwidthRTC/Types/ReadyMetadata.swift +++ b/Sources/BandwidthRTC/Types/ReadyMetadata.swift @@ -6,11 +6,43 @@ public struct ReadyMetadata: Decodable, Sendable { public let deviceId: String? public let territory: String? public let region: String? + public let connectStatus: ConnectStatus? + public let accountId: String? + public let sessionId: String? + public let from: String? + public let fromType: String? + public let fromTags: String? + public let to: String? + public let toType: String? + public let toTags: String? - public init(endpointId: String? = nil, deviceId: String? = nil, territory: String? = nil, region: String? = nil) { + public init( + endpointId: String? = nil, + deviceId: String? = nil, + territory: String? = nil, + region: String? = nil, + connectStatus: ConnectStatus? = nil, + accountId: String? = nil, + sessionId: String? = nil, + from: String? = nil, + fromType: String? = nil, + fromTags: String? = nil, + to: String? = nil, + toType: String? = nil, + toTags: String? = nil + ) { self.endpointId = endpointId self.deviceId = deviceId self.territory = territory self.region = region + self.connectStatus = connectStatus + self.accountId = accountId + self.sessionId = sessionId + self.from = from + self.fromType = fromType + self.fromTags = fromTags + self.to = to + self.toType = toType + self.toTags = toTags } } diff --git a/Tests/BandwidthRTCTests/BandwidthRTCTests.swift b/Tests/BandwidthRTCTests/BandwidthRTCTests.swift index f51e09f..376adad 100644 --- a/Tests/BandwidthRTCTests/BandwidthRTCTests.swift +++ b/Tests/BandwidthRTCTests/BandwidthRTCTests.swift @@ -407,6 +407,91 @@ final class BandwidthRTCTests: XCTestCase { XCTAssertTrue(streamCallbackFired) } + // MARK: - Connect Status + + func testConnectStatusEventFiresOnConnectStatusCallback() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + try await sut.connect(authParams: validAuthParams) + + var receivedMetadata: ReadyMetadata? + sut.onConnectStatus = { metadata in + receivedMetadata = metadata + } + + let statusJson = """ + {"connectStatus":"COMPLETED","accountId":"9900000","sessionId":"session-1","from":"ep-1","fromType":"ENDPOINT","fromTags":"tag1","to":"ep-2","toType":"ENDPOINT","toTags":"tag2"} + """.data(using: .utf8)! + sig.triggerEvent("connectStatus", data: statusJson) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(receivedMetadata?.connectStatus, .completed) + XCTAssertEqual(receivedMetadata?.accountId, "9900000") + XCTAssertEqual(receivedMetadata?.sessionId, "session-1") + XCTAssertEqual(receivedMetadata?.from, "ep-1") + XCTAssertEqual(receivedMetadata?.fromType, "ENDPOINT") + XCTAssertEqual(receivedMetadata?.fromTags, "tag1") + XCTAssertEqual(receivedMetadata?.to, "ep-2") + XCTAssertEqual(receivedMetadata?.toType, "ENDPOINT") + XCTAssertEqual(receivedMetadata?.toTags, "tag2") + } + + func testReadyEventWithConnectStatusFields() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + + var receivedMetadata: ReadyMetadata? + sut.onReady = { metadata in + receivedMetadata = metadata + } + try await sut.connect(authParams: validAuthParams) + + let readyJson = """ + {"endpointId":"test-ep","deviceId":"dev-1","territory":"US","region":"us-east-1","connectStatus":"TIMED_OUT","accountId":"1234","sessionId":"s-1","from":"ep-a","fromType":"ENDPOINT","to":"ep-b","toType":"PHONE_NUMBER"} + """.data(using: .utf8)! + sig.triggerEvent("ready", data: readyJson) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(receivedMetadata?.endpointId, "test-ep") + XCTAssertEqual(receivedMetadata?.connectStatus, .timedOut) + XCTAssertEqual(receivedMetadata?.accountId, "1234") + XCTAssertEqual(receivedMetadata?.to, "ep-b") + XCTAssertEqual(receivedMetadata?.toType, "PHONE_NUMBER") + } + + func testConnectStatusEventWithEmptyData() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + try await sut.connect(authParams: validAuthParams) + + var receivedMetadata: ReadyMetadata? + sut.onConnectStatus = { metadata in + receivedMetadata = metadata + } + + sig.triggerEvent("connectStatus") + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertNotNil(receivedMetadata) + XCTAssertNil(receivedMetadata?.connectStatus) + } + + func testConnectStatusEventRegistered() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + try await sut.connect(authParams: validAuthParams) + + XCTAssertTrue(sig.hasEventHandler(for: "connectStatus")) + } + + func testEstablishedEventNoLongerRegistered() async throws { + let sig = MockSignalingClient() + let sut = makeSUT(signaling: sig) + try await sut.connect(authParams: validAuthParams) + + XCTAssertFalse(sig.hasEventHandler(for: "established")) + } + func testCloseEventSetsNotConnected() async throws { let sig = MockSignalingClient() let sut = makeSUT(signaling: sig) diff --git a/VERSION b/VERSION index e6d5cb8..1cc5f65 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.2 \ No newline at end of file +1.1.0 \ No newline at end of file From 11c0da50e417b26948b5be9350c06b2129474fa4 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 29 Apr 2026 12:02:21 -0400 Subject: [PATCH 2/2] VAPI-2990 Fix connect status routing: gateway sends ready, not connectStatus The gateway intercepts connectStatus from the sidecar and forwards it to the SDK as a ready notification with connect status fields embedded. Remove the connectStatus event handler and instead route ready notifications to onConnectStatus when connectStatus is present, onReady otherwise. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Sources/BandwidthRTC/BandwidthRTC.swift | 24 ++++------- .../BandwidthRTCTests/BandwidthRTCTests.swift | 43 +++++++++---------- 2 files changed, 28 insertions(+), 39 deletions(-) diff --git a/Sources/BandwidthRTC/BandwidthRTC.swift b/Sources/BandwidthRTC/BandwidthRTC.swift index 975e48a..db02d5b 100644 --- a/Sources/BandwidthRTC/BandwidthRTC.swift +++ b/Sources/BandwidthRTC/BandwidthRTC.swift @@ -328,7 +328,9 @@ public final class BandwidthRTCClient: @unchecked Sendable { } } - // Handle ready event (may arrive after connect, e.g. for reconnection) + // Handle ready event. When connectStatus is present the gateway is reporting + // the outcome of a verb; route to onConnectStatus. Otherwise this + // is the normal session-ready signal; route to onReady. await signaling.onEvent("ready") { [weak self] data in guard let self else { return } @@ -339,23 +341,13 @@ public final class BandwidthRTCClient: @unchecked Sendable { metadata = (try? JSONDecoder().decode(ReadyMetadata.self, from: data)) ?? ReadyMetadata() } - Logger.shared.debug("Ready event: endpoint=\(metadata.endpointId ?? "nil")") - self.onReady?(metadata) - } - - // Handle connect status event (terminal outcome of a verb) - await signaling.onEvent("connectStatus") { [weak self] data in - guard let self else { return } - - let metadata: ReadyMetadata - if data.isEmpty { - metadata = ReadyMetadata() + if metadata.connectStatus != nil { + Logger.shared.debug("Connect status via ready: status=\(String(describing: metadata.connectStatus!))") + self.onConnectStatus?(metadata) } else { - metadata = (try? JSONDecoder().decode(ReadyMetadata.self, from: data)) ?? ReadyMetadata() + Logger.shared.debug("Ready event: endpoint=\(metadata.endpointId ?? "nil")") + self.onReady?(metadata) } - - Logger.shared.debug("Connect status event: status=\(metadata.connectStatus.map(String.init(describing:)) ?? "nil")") - self.onConnectStatus?(metadata) } // Handle disconnect diff --git a/Tests/BandwidthRTCTests/BandwidthRTCTests.swift b/Tests/BandwidthRTCTests/BandwidthRTCTests.swift index 376adad..056d4cf 100644 --- a/Tests/BandwidthRTCTests/BandwidthRTCTests.swift +++ b/Tests/BandwidthRTCTests/BandwidthRTCTests.swift @@ -409,7 +409,7 @@ final class BandwidthRTCTests: XCTestCase { // MARK: - Connect Status - func testConnectStatusEventFiresOnConnectStatusCallback() async throws { + func testReadyEventWithConnectStatusFiresOnConnectStatusCallback() async throws { let sig = MockSignalingClient() let sut = makeSUT(signaling: sig) try await sut.connect(authParams: validAuthParams) @@ -419,10 +419,10 @@ final class BandwidthRTCTests: XCTestCase { receivedMetadata = metadata } - let statusJson = """ - {"connectStatus":"COMPLETED","accountId":"9900000","sessionId":"session-1","from":"ep-1","fromType":"ENDPOINT","fromTags":"tag1","to":"ep-2","toType":"ENDPOINT","toTags":"tag2"} + let readyJson = """ + {"endpointId":"ep-1","deviceId":"dev-1","connectStatus":"COMPLETED","accountId":"9900000","sessionId":"session-1","from":"ep-1","fromType":"ENDPOINT","fromTags":"tag1","to":"ep-2","toType":"ENDPOINT","toTags":"tag2"} """.data(using: .utf8)! - sig.triggerEvent("connectStatus", data: statusJson) + sig.triggerEvent("ready", data: readyJson) try await Task.sleep(for: .milliseconds(50)) XCTAssertEqual(receivedMetadata?.connectStatus, .completed) @@ -436,52 +436,49 @@ final class BandwidthRTCTests: XCTestCase { XCTAssertEqual(receivedMetadata?.toTags, "tag2") } - func testReadyEventWithConnectStatusFields() async throws { + func testReadyEventWithConnectStatusDoesNotFireOnReady() async throws { let sig = MockSignalingClient() let sut = makeSUT(signaling: sig) - - var receivedMetadata: ReadyMetadata? - sut.onReady = { metadata in - receivedMetadata = metadata - } try await sut.connect(authParams: validAuthParams) + var onReadyFired = false + sut.onReady = { _ in onReadyFired = true } + let readyJson = """ - {"endpointId":"test-ep","deviceId":"dev-1","territory":"US","region":"us-east-1","connectStatus":"TIMED_OUT","accountId":"1234","sessionId":"s-1","from":"ep-a","fromType":"ENDPOINT","to":"ep-b","toType":"PHONE_NUMBER"} + {"endpointId":"ep-1","connectStatus":"COMPLETED","accountId":"1234"} """.data(using: .utf8)! sig.triggerEvent("ready", data: readyJson) try await Task.sleep(for: .milliseconds(50)) - XCTAssertEqual(receivedMetadata?.endpointId, "test-ep") - XCTAssertEqual(receivedMetadata?.connectStatus, .timedOut) - XCTAssertEqual(receivedMetadata?.accountId, "1234") - XCTAssertEqual(receivedMetadata?.to, "ep-b") - XCTAssertEqual(receivedMetadata?.toType, "PHONE_NUMBER") + XCTAssertFalse(onReadyFired) } - func testConnectStatusEventWithEmptyData() async throws { + func testReadyEventWithoutConnectStatusFiresOnReady() async throws { let sig = MockSignalingClient() let sut = makeSUT(signaling: sig) - try await sut.connect(authParams: validAuthParams) var receivedMetadata: ReadyMetadata? - sut.onConnectStatus = { metadata in + sut.onReady = { metadata in receivedMetadata = metadata } + try await sut.connect(authParams: validAuthParams) - sig.triggerEvent("connectStatus") + let readyJson = """ + {"endpointId":"test-ep","deviceId":"dev-1","territory":"US","region":"us-east-1"} + """.data(using: .utf8)! + sig.triggerEvent("ready", data: readyJson) try await Task.sleep(for: .milliseconds(50)) - XCTAssertNotNil(receivedMetadata) + XCTAssertEqual(receivedMetadata?.endpointId, "test-ep") XCTAssertNil(receivedMetadata?.connectStatus) } - func testConnectStatusEventRegistered() async throws { + func testConnectStatusEventNoLongerRegistered() async throws { let sig = MockSignalingClient() let sut = makeSUT(signaling: sig) try await sut.connect(authParams: validAuthParams) - XCTAssertTrue(sig.hasEventHandler(for: "connectStatus")) + XCTAssertFalse(sig.hasEventHandler(for: "connectStatus")) } func testEstablishedEventNoLongerRegistered() async throws {