Skip to content

Commit 2e21ae8

Browse files
renal128cursoragent
andcommitted
fix: harden widget session lifecycle and cancel startup cleanly
Keep widget config/mode live, cancel credential mint before bind, and avoid stale connected/tool work so end/disappear can tear down a session safely. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1ec10cc commit 2e21ae8

20 files changed

Lines changed: 86 additions & 62 deletions

Sources/ElevenLabs/Internal/Conversation/Conversation.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ final class Conversation: ObservableObject {
123123
try await startVoiceConversation(auth: auth)
124124
}
125125

126+
guard !Task.isCancelled, state.isConnecting else {
127+
await activeConnectionManager?.disconnect()
128+
throw CancellationError()
129+
}
126130
state = .connected(result.callInfo)
127131
callbacks.onAgentReady?()
128132
return result
@@ -417,9 +421,10 @@ final class Conversation: ObservableObject {
417421
}
418422

419423
private func handleStartupCancellation(disconnecting connectionManager: any ConnectionManaging) async {
420-
guard state.isConnecting else { return }
421-
state = .ended(reason: .userEnded)
422-
tearDownActiveSession()
424+
if state.isConnecting {
425+
state = .ended(reason: .userEnded)
426+
tearDownActiveSession()
427+
}
423428
await connectionManager.disconnect()
424429
}
425430

Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
104104

105105
// 2. Request microphone permission (denial doesn't block startup).
106106
let permissionGranted = await requestMicrophonePermission()
107+
try Task.checkCancellation()
107108

108109
// 3. Connect the LiveKit room.
109110
onStartupStateChange(.connectingRoom)

Sources/ElevenLabsWidget/ChatWidgetController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import ElevenLabs
33
import Foundation
44

Sources/ElevenLabsWidget/Configuration/ChatWidgetConfig.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import Foundation
33

44
/// Behavior and appearance of ``ChatWidget``.

Sources/ElevenLabsWidget/Configuration/ChatWidgetStrings.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import Foundation
33

44
/// User-facing copy for ``ChatWidget``. Override individual fields to localize

Sources/ElevenLabsWidget/Configuration/ChatWidgetTheme.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import SwiftUI
33

44
/// Visual styling for ``ChatWidget``. Defaults mirror the ElevenLabs web widget.

Sources/ElevenLabsWidget/Configuration/WidgetConversationMode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import ElevenLabs
33
import Foundation
44

Sources/ElevenLabsWidget/Models/ChatMessage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import ElevenLabs
33
import Foundation
44

Sources/ElevenLabsWidget/Models/ChatWidgetBanner.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import AVFoundation
33
import Foundation
44
import UIKit

Sources/ElevenLabsWidget/ViewModels/ChatWidgetViewModel.swift

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#if canImport(UIKit)
1+
#if os(iOS)
22
import Combine
33
import ElevenLabs
44
import Foundation
@@ -29,14 +29,14 @@ final class ChatWidgetViewModel: ObservableObject {
2929
let endedByUser: Bool
3030
}
3131

32-
/// All three mirror what the host passes to ``ChatWidget``, so changes land
32+
/// All four mirror what the host passes to ``ChatWidget``, so changes land
3333
/// on the live view model instead of needing a new conversation.
3434
var mode: WidgetConversationMode
35+
var conversationConfig: ConversationConfig
3536
var onClientToolCall: (@MainActor (ClientToolCallEvent) async -> ClientToolResultEvent)?
3637
@Published var widgetConfig: ChatWidgetConfig
3738
let client: ConversationClient
3839

39-
private let conversationConfig: ConversationConfig
4040
/// Tool calls already dispatched, so a re-published snapshot doesn't run them twice.
4141
private var dispatchedToolCallIds: Set<String> = []
4242
/// The in-flight startup, so overlapping callers join it instead of racing.
@@ -69,6 +69,9 @@ final class ChatWidgetViewModel: ObservableObject {
6969
.map { $0.map(ChatMessage.init) }
7070
.assign(to: &$messages)
7171
client.$pendingToolCalls
72+
.combineLatest(client.$state)
73+
.filter { _, state in state.isConnected }
74+
.map { calls, _ in calls }
7275
.sink { [weak self] in self?.dispatchNewToolCalls($0) }
7376
.store(in: &cancellables)
7477
// Only a finished session clears the kind: the client rebinds to a fresh
@@ -80,6 +83,7 @@ final class ChatWidgetViewModel: ObservableObject {
8083
switch state {
8184
case let .ended(reason):
8285
sessionKind = nil
86+
dismissBanner()
8387
endedConversation = EndedConversation(
8488
id: client.conversationMetadata?.conversationId,
8589
endedByUser: reason == .userEnded
@@ -185,7 +189,6 @@ final class ChatWidgetViewModel: ObservableObject {
185189
Task {
186190
do {
187191
try await startConversationAndWait()
188-
warnIfMicrophoneUnavailable()
189192
} catch is CancellationError {
190193
// The user ended the call before it connected.
191194
} catch {
@@ -219,10 +222,14 @@ final class ChatWidgetViewModel: ObservableObject {
219222
// No start in flight, so a kind here means a session already connected.
220223
guard sessionKind == nil else { return }
221224
let mode = mode
225+
let client = client
222226
let config = conversationConfig(for: kind)
227+
dispatchedToolCallIds.removeAll()
223228
let task = Task {
229+
let credentials = try await mode.credentials(for: kind)
230+
try Task.checkCancellation()
224231
_ = try await client.startConversation(
225-
auth: mode.credentials(for: kind),
232+
auth: credentials,
226233
config: config
227234
)
228235
}
@@ -236,19 +243,22 @@ final class ChatWidgetViewModel: ObservableObject {
236243
defer { if sessionGeneration == generation { startTask = nil } }
237244
do {
238245
try await task.value
246+
warnIfMicrophoneUnavailable()
239247
} catch {
240248
// Minting can fail before the client ever leaves idle, and then no
241249
// state arrives to retire the session this would have been.
242250
if sessionGeneration == generation { sessionKind = nil }
251+
if task.isCancelled || error is CancellationError {
252+
throw CancellationError()
253+
}
243254
throw error
244255
}
245256
}
246257

247258
/// Text-only sessions run the conversation without audio.
248259
private func conversationConfig(for kind: WidgetSessionKind) -> ConversationConfig {
249-
guard kind == .textOnly else { return conversationConfig }
250260
var config = conversationConfig
251-
config.conversationOverrides.textOnly = true
261+
config.conversationOverrides.textOnly = kind == .textOnly
252262
return config
253263
}
254264

@@ -259,28 +269,24 @@ final class ChatWidgetViewModel: ObservableObject {
259269
/// Ending has to outrank a start that is still resolving credentials, or the
260270
/// connection lands afterwards and leaves the microphone live.
261271
func endConversationAndWait() async {
262-
let start = startTask
263-
let generation = sessionGeneration
272+
let task = startTask?.task
264273
startTask = nil
265274
sessionKind = nil
266-
start?.task.cancel()
275+
task?.cancel()
267276
await client.endConversation()
268-
guard let start else { return }
269-
// A start that ignored the cancel still connects, so wait it out and
270-
// tear it down — unless a newer session has begun in the meantime.
271-
_ = try? await start.task.value
272-
if sessionGeneration == generation { await client.endConversation() }
273277
}
274278

275279
func send() {
276280
let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
277281
guard !text.isEmpty, !isSending else { return }
278282
input = ""
279283
isSending = true
280-
dismissBanner()
284+
if banner?.offersSettings != true { dismissBanner() }
281285
Task {
282286
do {
283287
try await send(text)
288+
} catch is CancellationError {
289+
if input.isEmpty { input = text }
284290
} catch {
285291
// Hand the text back so the user can retry rather than losing it.
286292
if input.isEmpty { input = text }
@@ -304,17 +310,20 @@ final class ChatWidgetViewModel: ObservableObject {
304310
private func show(_ banner: ChatWidgetBanner) {
305311
self.banner = banner
306312
bannerDismissal?.cancel()
313+
bannerDismissal = nil
307314
// A banner offering Settings needs to stay until it is acted on.
308315
guard !banner.offersSettings else { return }
309316
bannerDismissal = Task { [weak self] in
310317
try? await Task.sleep(nanoseconds: 5_000_000_000)
311318
guard !Task.isCancelled else { return }
312319
self?.banner = nil
320+
self?.bannerDismissal = nil
313321
}
314322
}
315323

316324
func dismissBanner() {
317325
bannerDismissal?.cancel()
326+
bannerDismissal = nil
318327
banner = nil
319328
}
320329

@@ -328,36 +337,33 @@ final class ChatWidgetViewModel: ObservableObject {
328337
private func dispatchNewToolCalls(_ calls: [ClientToolCallEvent]) {
329338
let ids = Set(calls.map(\.toolCallId))
330339
let added = ids.subtracting(dispatchedToolCallIds)
331-
dispatchedToolCallIds = ids
340+
dispatchedToolCallIds.formUnion(added)
332341
for call in calls where added.contains(call.toolCallId) {
333342
dispatch(call)
334343
}
335344
}
336345

337346
private func dispatch(_ call: ClientToolCallEvent) {
338-
guard let onClientToolCall else {
339-
respond(to: call, with: .init(
340-
toolCallId: call.toolCallId,
341-
result: "No client tool handler is configured in the app.",
342-
isError: true
343-
))
344-
return
345-
}
346347
let generation = sessionGeneration
348+
let handler = onClientToolCall
347349
Task { @MainActor [weak self] in
348-
let result = await onClientToolCall(call)
350+
let result = if let handler {
351+
await handler(call)
352+
} else {
353+
ClientToolResultEvent(
354+
toolCallId: call.toolCallId,
355+
result: "No client tool handler is configured in the app.",
356+
isError: true
357+
)
358+
}
349359
// The host handler can outlive the conversation that asked for it.
350360
guard let self, generation == sessionGeneration else { return }
351-
respond(to: call, with: result)
352-
}
353-
}
354-
355-
private func respond(to call: ClientToolCallEvent, with result: ClientToolResultEvent) {
356-
guard call.expectsResponse else {
357-
client.markToolCallCompleted(call.toolCallId)
358-
return
361+
if call.expectsResponse {
362+
try? await client.sendToolResult(result)
363+
} else {
364+
client.markToolCallCompleted(call.toolCallId)
365+
}
359366
}
360-
Task { try? await client.sendToolResult(result) }
361367
}
362368
}
363369
#endif

0 commit comments

Comments
 (0)