Skip to content

Commit 4d3ad55

Browse files
authored
fix(startup): gate readiness on agent join, not audio-track subscription (#197)
Wait for the first remote participant to join before sending conversation_init, instead of waiting for the agent's audio track to subscribe. Participant-join is the direct "safe to send" signal; LiveKit auto-plays remote audio, so playback never depended on the track-subscription wait. LiveKitReadinessDelegate now resolves on roomDidConnect / participantDidConnect, dropping the Stage machine, checkForSubscribedAudio, and didSubscribeTrack. Internal-only, no public API change. Adds a signature-guard test for the delegate's RoomDelegate methods.
1 parent 10b5100 commit 4d3ad55

2 files changed

Lines changed: 59 additions & 64 deletions

File tree

Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift

Lines changed: 37 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
133133
return StartupResult(agentId: auth.agentId, metrics: metrics)
134134
}
135135

136-
/// Race the delegate's "agent audio track subscribed" signal against `timeout`.
136+
/// Race the delegate's "first remote participant joined" signal against `timeout`.
137137
/// A `.timedOut` result makes `connect` fail with `StartupFailure.agentTimeout`.
138138
private func waitForAgentReady(timeout: TimeInterval) async -> AgentReadyWaitResult {
139139
guard let delegate = readinessDelegate else {
@@ -143,7 +143,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
143143
return await withTaskGroup(of: AgentReadyWaitResult.self) { group in
144144
group.addTask {
145145
do {
146-
try await delegate.awaitSubscription()
146+
try await delegate.awaitRemoteParticipant()
147147
return .success(elapsed: Date().timeIntervalSince(start))
148148
} catch {
149149
return .timedOut(elapsed: Date().timeIntervalSince(start))
@@ -321,6 +321,11 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
321321
}
322322
}
323323

324+
/// The agent joins with an identity prefixed `agent`; other participants don't.
325+
private func isAgentParticipant(_ participant: Participant) -> Bool {
326+
(participant.identity.map { String(describing: $0) } ?? "").hasPrefix("agent")
327+
}
328+
324329
// MARK: – Room event delegate
325330

326331
/// `RoomDelegate` that forwards data, remote speaking, and remote-disconnect events
@@ -353,8 +358,7 @@ final class LiveKitRoomEventDelegate: RoomDelegate {
353358
}
354359

355360
nonisolated func room(_ room: Room, participantDidDisconnect participant: RemoteParticipant) {
356-
let identityString = participant.identity.map { String(describing: $0) } ?? ""
357-
guard identityString.hasPrefix("agent") || room.remoteParticipants.isEmpty else { return }
361+
guard isAgentParticipant(participant) || room.remoteParticipants.isEmpty else { return }
358362
Task { [onRemoteDisconnect] in
359363
await onRemoteDisconnect()
360364
}
@@ -370,101 +374,70 @@ final class LiveKitRoomEventDelegate: RoomDelegate {
370374

371375
// MARK: – Readiness delegate
372376

373-
/// Observes LiveKit and signals exactly one event: the agent's audio track is
374-
/// subscribed (the real "safe to send" signal). Holds no timing policy; callers
375-
/// race `awaitSubscription()` against their own timeout.
377+
/// Observes LiveKit and signals exactly one event: the first remote participant
378+
/// has joined the room (the "safe to send" signal). Holds no timing policy; callers
379+
/// race `awaitRemoteParticipant()` against their own timeout.
376380
@MainActor
377-
private final class LiveKitReadinessDelegate: RoomDelegate {
378-
private enum Stage { case waiting, ready, released }
379-
381+
final class LiveKitReadinessDelegate: RoomDelegate {
380382
private let logger: any Logging
381-
private var stage: Stage = .waiting
382-
private var awaiter: CheckedContinuation<Void, Error>?
383+
private var continuation: CheckedContinuation<Void, Error>?
384+
private var outcome: Result<Void, Error>?
383385

384386
init(logger: any Logging) {
385387
self.logger = logger
386388
}
387389

388-
/// Suspend until the agent's audio track is subscribed. Throws `CancellationError`
390+
/// Suspend until the first remote participant joins. Throws `CancellationError`
389391
/// if the awaiting task is cancelled or `release()` is called (e.g. on disconnect).
390392
/// Single-caller — there's exactly one `waitForAgentReady` per connection lifetime.
391-
func awaitSubscription() async throws {
392-
switch stage {
393-
case .ready: return
394-
case .released: throw CancellationError()
395-
case .waiting: break
396-
}
393+
func awaitRemoteParticipant() async throws {
394+
if let outcome { return try outcome.get() }
397395
try await withTaskCancellationHandler {
398396
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
399-
if Task.isCancelled {
397+
if let outcome {
398+
cont.resume(with: outcome)
399+
} else if Task.isCancelled {
400400
cont.resume(throwing: CancellationError())
401-
return
402-
}
403-
switch stage {
404-
case .ready: cont.resume()
405-
case .released: cont.resume(throwing: CancellationError())
406-
case .waiting: awaiter = cont
401+
} else {
402+
continuation = cont
407403
}
408404
}
409405
} onCancel: {
410-
Task { @MainActor [weak self] in
411-
if let cont = self?.awaiter {
412-
self?.awaiter = nil
413-
cont.resume(throwing: CancellationError())
414-
}
415-
}
406+
Task { @MainActor [weak self] in self?.finish(.failure(CancellationError())) }
416407
}
417408
}
418409

419410
/// Resolve the in-flight awaiter (if any) with `CancellationError`. Called by
420411
/// the manager on disconnect (or before swapping in a fresh delegate on reconnect).
421412
func release() {
422-
guard stage != .released else { return }
423-
stage = .released
424-
if let cont = awaiter {
425-
awaiter = nil
426-
cont.resume(throwing: CancellationError())
427-
}
413+
finish(.failure(CancellationError()))
428414
}
429415

430416
// MARK: - RoomDelegate
431417

432418
nonisolated func roomDidConnect(_ room: Room) {
433-
Task { @MainActor in self.checkForSubscribedAudio(in: room) }
434-
}
435-
436-
nonisolated func room(_ room: Room, participantDidConnect _: RemoteParticipant) {
437-
Task { @MainActor in self.checkForSubscribedAudio(in: room) }
419+
Task { @MainActor in
420+
if room.remoteParticipants.values.contains(where: isAgentParticipant) { self.markReady() }
421+
}
438422
}
439423

440-
nonisolated func room(
441-
_: Room,
442-
participant _: RemoteParticipant,
443-
didSubscribeTrack publication: RemoteTrackPublication
444-
) {
424+
nonisolated func room(_: Room, participantDidConnect participant: RemoteParticipant) {
445425
Task { @MainActor in
446-
guard publication.kind == .audio else { return }
447-
self.markReady()
426+
if isAgentParticipant(participant) { self.markReady() }
448427
}
449428
}
450429

451430
// MARK: - Private
452431

453-
private func checkForSubscribedAudio(in room: Room) {
454-
guard stage == .waiting else { return }
455-
let hasSubscribed = room.remoteParticipants.values.contains { participant in
456-
participant.audioTracks.contains { $0.isSubscribed && $0.track != nil }
457-
}
458-
if hasSubscribed { markReady() }
432+
private func markReady() {
433+
logger.debug("Agent joined")
434+
finish(.success(()))
459435
}
460436

461-
private func markReady() {
462-
guard stage == .waiting else { return }
463-
stage = .ready
464-
logger.debug("Agent audio track subscribed")
465-
if let cont = awaiter {
466-
awaiter = nil
467-
cont.resume()
468-
}
437+
private func finish(_ result: Result<Void, Error>) {
438+
guard outcome == nil else { return }
439+
outcome = result
440+
continuation?.resume(with: result)
441+
continuation = nil
469442
}
470443
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@testable import ElevenLabs
2+
import LiveKit
3+
import XCTest
4+
5+
/// `RoomDelegate` methods are `@objc optional`, so a wrong signature compiles but
6+
/// is never called. These references only type-check against genuine requirements.
7+
@MainActor
8+
final class LiveKitReadinessDelegateTests: XCTestCase {
9+
private func makeDelegate() -> RoomDelegate {
10+
LiveKitReadinessDelegate(logger: SDKLogger(logLevel: .warning))
11+
}
12+
13+
func testImplementsGenuineRoomDelegateMethods() {
14+
let delegate = makeDelegate()
15+
16+
let onConnect: ((Room) -> Void)? = delegate.roomDidConnect
17+
let onParticipantConnect: ((Room, RemoteParticipant) -> Void)? = delegate.room(_:participantDidConnect:)
18+
19+
XCTAssertNotNil(onConnect, "roomDidConnect must be a real RoomDelegate requirement")
20+
XCTAssertNotNil(onParticipantConnect, "participantDidConnect must be a real RoomDelegate requirement")
21+
}
22+
}

0 commit comments

Comments
 (0)