Skip to content

Commit df6cf45

Browse files
committed
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.
1 parent b2b4387 commit df6cf45

7 files changed

Lines changed: 663 additions & 132 deletions

File tree

Sources/ElevenLabs/Internal/Conversation/AgentStateManager.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ private typealias TimerTask = Task<Void, Never>
1515

1616
@MainActor
1717
final class AgentStateManager {
18-
private(set) var currentState: ElevenLabs.AgentState = .listening
19-
var onStateChange: ((ElevenLabs.AgentState) -> Void)?
18+
private(set) var currentState: AgentState = .listening
19+
var onStateChange: ((AgentState) -> Void)?
2020

2121
private let configuration: AgentStateConfiguration
2222

@@ -90,7 +90,7 @@ final class AgentStateManager {
9090
}
9191
}
9292

93-
private func transitionTo(_ newState: ElevenLabs.AgentState) {
93+
private func transitionTo(_ newState: AgentState) {
9494
guard newState != currentState else { return }
9595
currentState = newState
9696
onStateChange?(newState)

Sources/ElevenLabs/Internal/Conversation/Conversation+Events.swift

Lines changed: 148 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -8,132 +8,208 @@ extension Conversation {
88
func handleIncomingEvent(_ event: IncomingEvent) async {
99
switch event {
1010
case let .userTranscript(e):
11-
insertUserTranscript(content: e.transcript, eventId: e.eventId)
11+
applyUserTranscript(content: e.transcript, eventId: e.eventId)
12+
callbacks.onUserTranscript?(e.transcript, e.eventId)
1213
agentStateManager?.processSignal(.userTranscript)
13-
options.onUserTranscript?(e.transcript, e.eventId)
14+
15+
case let .tentativeUserTranscript(e):
16+
applyTentativeUserTranscript(content: e.transcript, eventId: e.eventId)
17+
callbacks.onTentativeUserTranscript?(e.transcript, e.eventId)
1418

1519
case let .agentResponse(e):
16-
upsertAgentMessage(content: e.response, eventId: e.eventId)
17-
lastAgentEventId = e.eventId
20+
applyAgentResponse(content: e.response, eventId: e.eventId)
21+
callbacks.onAgentResponse?(e.response, e.eventId)
1822
agentStateManager?.processSignal(.agentResponse)
19-
options.onAgentResponse?(e.response, e.eventId)
20-
if lastFeedbackSubmittedEventId.map({ e.eventId > $0 }) ?? true {
21-
options.onCanSendFeedbackChange?(true)
22-
}
2323

2424
case let .agentResponseCorrection(correction):
25-
upsertAgentMessage(content: correction.correctedAgentResponse, eventId: correction.eventId)
26-
options.onAgentResponseCorrection?(
25+
applyAgentResponse(
26+
content: correction.correctedAgentResponse,
27+
eventId: correction.eventId
28+
)
29+
callbacks.onAgentResponseCorrection?(
2730
correction.originalAgentResponse,
2831
correction.correctedAgentResponse,
2932
correction.eventId
3033
)
3134

35+
case let .agentChatResponsePart(e):
36+
applyAgentResponsePart(text: e.text, type: e.type, eventId: e.eventId)
37+
callbacks.onAgentResponsePart?(e.text, e.type, e.eventId)
38+
3239
case let .agentResponseMetadata(metadata):
33-
options.onAgentResponseMetadata?(
40+
callbacks.onAgentResponseMetadata?(
3441
metadata.metadataData,
3542
metadata.eventId
3643
)
3744

38-
case let .agentChatResponsePart(e):
39-
let existing = messages.last(where: { $0.role == .agent && $0.eventId == e.eventId })?.content ?? ""
40-
upsertAgentMessage(content: existing + e.text, eventId: e.eventId)
41-
4245
case let .audio(audioEvent):
43-
latestAudioEvent = audioEvent
44-
latestAudioAlignment = audioEvent.alignment
4546
if let alignment = audioEvent.alignment {
46-
options.onAudioAlignment?(alignment)
47+
callbacks.onAudioAlignment?(alignment)
4748
}
4849

4950
case let .interruption(interruptionEvent):
5051
speakingTimer?.cancel()
51-
applyStateSignal(.interruption, fallback: .listening)
52-
options.onInterruption?(interruptionEvent.eventId)
53-
options.onCanSendFeedbackChange?(false)
52+
isAgentSpeaking = false
53+
feedAgentState(.interruption, fallback: .listening)
54+
callbacks.onInterruption?(interruptionEvent.eventId)
5455

5556
case let .conversationMetadata(metadata):
56-
// Store the conversation metadata for public access
5757
conversationMetadata = metadata
58-
options.onConversationMetadata?(metadata)
58+
// This event completes the startup handshake: release any waiter
59+
// blocking `connect()` on metadata receipt.
60+
resumeConversationMetadataWaiter()
5961

6062
case let .ping(p):
61-
// Respond to ping with pong
62-
let pong = OutgoingEvent.pong(PongEvent(eventId: p.eventId))
63-
try? await publish(pong)
64-
65-
case let .clientToolCall(toolCall):
66-
// Add to pending tool calls for the app to handle
67-
options.onUnhandledClientToolCall?(toolCall)
68-
pendingToolCalls.append(toolCall)
63+
if let pingMs = p.pingMs {
64+
callbacks.onPing?(pingMs)
65+
}
66+
// Send pong off the serialized handler loop: awaiting the publish
67+
// here would let a slow transport stall delivery of every queued
68+
// event behind this heartbeat. Pong is keyed by `eventId`, so
69+
// out-of-order delivery is fine.
70+
let eventId = p.eventId
71+
Task { @MainActor [weak self] in
72+
try? await self?.publish(.pong(PongEvent(eventId: eventId)))
73+
}
6974

7075
case let .vadScore(vad):
76+
callbacks.onVadScore?(vad.vadScore)
7177
agentStateManager?.processSignal(.vadScore(vad.vadScore))
72-
options.onVadScore?(vad.vadScore)
73-
74-
case let .agentToolResponse(toolResponse):
75-
applyStateSignal(.agentToolResponse, fallback: .listening)
7678

77-
if toolResponse.toolName == "end_call" {
78-
await endConversation()
79-
}
80-
options.onAgentToolResponse?(toolResponse)
79+
case let .clientToolCall(toolCall):
80+
// Append before invoking the callback so a handler that inspects
81+
// `pendingToolCalls` (directly or via the mirrored client property)
82+
// already sees the new call.
83+
pendingToolCalls.append(toolCall)
84+
callbacks.onClientToolCall?(toolCall)
8185

8286
case let .agentToolRequest(toolRequest):
83-
applyStateSignal(.agentToolRequest, fallback: .thinking)
84-
options.onAgentToolRequest?(toolRequest)
87+
feedAgentState(.agentToolRequest, fallback: .thinking)
88+
callbacks.onAgentToolRequest?(toolRequest)
8589

86-
case .tentativeUserTranscript:
87-
// Tentative user transcript (in-progress transcription)
88-
break
90+
case let .agentToolResponse(toolResponse):
91+
feedAgentState(.agentToolResponse, fallback: .listening)
92+
callbacks.onAgentToolResponse?(toolResponse)
8993

9094
case let .mcpToolCall(toolCall):
91-
// Update or append MCP tool call based on toolCallId
9295
if let index = mcpToolCalls.firstIndex(where: { $0.toolCallId == toolCall.toolCallId }) {
9396
mcpToolCalls[index] = toolCall
9497
} else {
9598
mcpToolCalls.append(toolCall)
9699
}
97100

98101
case let .mcpConnectionStatus(status):
99-
// Update MCP connection status
100102
mcpConnectionStatus = status
101103

102104
case let .error(errorEvent):
103-
logger.error("Received error event from server: code=\(errorEvent.code), message=\(errorEvent.message ?? "none")")
104-
options.onError?(.serverError(errorEvent))
105+
logger.error("Received error event from server: code=\(errorEvent.code), name=\(errorEvent.errorName ?? "none"), message=\(errorEvent.message ?? "none")")
106+
callbacks.onError?(.serverError(errorEvent))
105107
}
106108
}
107109

108-
/// Inserts the user transcript before the agent message with the same `eventId`
109-
/// if one exists, since the agent's response may be received before the transcript.
110-
private func insertUserTranscript(content: String, eventId: Int) {
111-
let message = Message(
112-
id: UUID().uuidString,
113-
role: .user,
114-
content: content,
115-
timestamp: Date(),
116-
eventId: eventId
117-
)
118-
if let agentIdx = messages.firstIndex(where: { $0.role == .agent && $0.eventId == eventId }) {
119-
messages.insert(message, at: agentIdx)
110+
// MARK: - Transcript / response reconciliation
111+
//
112+
// `messages` is keyed by role + event id and only ever appended, never
113+
// reordered, so order follows the arrival of finalized text:
114+
// * Finalized text (`agent_response`, `agent_response_correction`,
115+
// `user_transcript`) is always recorded — a matching event id updates in
116+
// place, otherwise it's appended (even out of order).
117+
// * Streaming parts (`agent_chat_response_part`, `tentative_user_transcript`)
118+
// only open a new partial when their event id is newer than the role's
119+
// highest; otherwise they're stale and ignored.
120+
// Event ids stay unique per role; partial user transcripts are cleared on
121+
// every tentative/final user transcript.
122+
123+
/// `agent_chat_response_part`: accumulates streamed text. A finalized message
124+
/// (`.stop` already seen) is never reopened, and a stale part (older than the
125+
/// agent's highest event id) never opens a new bubble.
126+
private func applyAgentResponsePart(text: String, type: AgentChatResponsePartType, eventId: Int) {
127+
let isPartial = type != .stop
128+
guard let idx = messageIndex(role: .agent, eventId: eventId) else {
129+
if isNewerThanHighestEventId(role: .agent, eventId: eventId) {
130+
appendMessage(role: .agent, content: text, eventId: eventId, isPartial: isPartial)
131+
}
132+
return
133+
}
134+
guard messages[idx].isPartial else { return }
135+
messages[idx] = messages[idx].updating(content: messages[idx].content + text, eventId: eventId, isPartial: isPartial)
136+
}
137+
138+
/// `agent_response` | `agent_response_correction`: the finalized response for a
139+
/// turn. Replaces the matching message in place, or records it (appending,
140+
/// even out of order) when no slot exists yet.
141+
private func applyAgentResponse(content: String, eventId: Int) {
142+
if let idx = messageIndex(role: .agent, eventId: eventId) {
143+
messages[idx] = messages[idx].updating(content: content, eventId: eventId, isPartial: false)
120144
} else {
121-
messages.append(message)
145+
appendMessage(role: .agent, content: content, eventId: eventId, isPartial: false)
122146
}
123147
}
124148

125-
private func upsertAgentMessage(content: String, eventId: Int) {
126-
if let idx = messages.lastIndex(where: { $0.role == .agent && $0.eventId == eventId }) {
127-
let existing = messages[idx]
128-
messages[idx] = Message(
129-
id: existing.id,
130-
role: .agent,
131-
content: content,
132-
timestamp: existing.timestamp,
133-
eventId: eventId
134-
)
149+
/// `user_transcript`: the finalized user transcript. Finalizes the matching
150+
/// in-progress partial, or records it (appending, even out of order) when no
151+
/// slot exists; then drops any leftover partial (a tentative that never
152+
/// produced its own final).
153+
private func applyUserTranscript(content: String, eventId: Int) {
154+
if let idx = messageIndex(role: .user, eventId: eventId) {
155+
messages[idx] = messages[idx].updating(content: content, eventId: eventId, isPartial: false)
135156
} else {
136-
appendMessage(role: .agent, content: content, eventId: eventId)
157+
appendMessage(role: .user, content: content, eventId: eventId, isPartial: false)
137158
}
159+
// A finalized transcript ends the turn, so any leftover in-progress
160+
// partial is stale and removed.
161+
messages.removeAll { $0.role == .user && $0.isPartial }
162+
}
163+
164+
/// `tentative_user_transcript`: the in-progress user transcript. Supersedes any
165+
/// existing partial, then surfaces a fresh one if it belongs to a turn newer
166+
/// than the user's highest event id.
167+
private func applyTentativeUserTranscript(content: String, eventId: Int) {
168+
messages.removeAll { $0.role == .user && $0.isPartial }
169+
guard isNewerThanHighestEventId(role: .user, eventId: eventId) else { return }
170+
appendMessage(role: .user, content: content, eventId: eventId, isPartial: true)
171+
}
172+
173+
/// Index of the `role` message with exactly `eventId`, scanning tail-first so
174+
/// the common "touch the latest message" case is cheap. Event ids are unique
175+
/// per role (matches update in place), so first/last match the same element.
176+
private func messageIndex(role: Message.Role, eventId: Int) -> Int? {
177+
messages.lastIndex { $0.role == role && $0.eventId == eventId }
178+
}
179+
180+
/// Whether `eventId` is greater than the highest event id recorded for `role`.
181+
/// Uses the max rather than the last message, because finalized responses can
182+
/// append out of order and locally-sent messages carry no event id (skipped).
183+
private func isNewerThanHighestEventId(role: Message.Role, eventId: Int) -> Bool {
184+
guard let highest = messages.compactMap({ $0.role == role ? $0.eventId : nil }).max() else { return true }
185+
return eventId > highest
186+
}
187+
188+
private func appendMessage(role: Message.Role, content: String, eventId: Int, isPartial: Bool) {
189+
messages.append(
190+
Message(
191+
id: UUID().uuidString,
192+
role: role,
193+
content: content,
194+
timestamp: Date(),
195+
eventId: eventId,
196+
isPartial: isPartial
197+
)
198+
)
199+
}
200+
}
201+
202+
private extension Message {
203+
/// A copy with new `content`/`eventId`/`isPartial`, preserving the stable
204+
/// `id`, `role`, and `timestamp` so SwiftUI identity and ordering hold.
205+
func updating(content: String, eventId: Int?, isPartial: Bool) -> Message {
206+
Message(
207+
id: id,
208+
role: role,
209+
content: content,
210+
timestamp: timestamp,
211+
eventId: eventId,
212+
isPartial: isPartial
213+
)
138214
}
139215
}

Sources/ElevenLabs/Internal/Utilities/EventSerializer.swift

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ enum EventSerializer {
1010
json["type"] = "pong"
1111
json["event_id"] = pongEvent.eventId
1212

13-
case let .userAudio(audioEvent):
14-
json["user_audio_chunk"] = audioEvent.audioChunk
15-
1613
case let .conversationInit(initEvent):
1714
json["type"] = "conversation_initiation_client_data"
1815
if let config = initEvent.config {
@@ -29,7 +26,9 @@ enum EventSerializer {
2926
json["tool_call_id"] = resultEvent.toolCallId
3027
json["result"] = resultEvent.result
3128
json["is_error"] = resultEvent.isError
32-
json["error_type"] = resultEvent.errorType?.rawValue
29+
if let errorType = resultEvent.errorType {
30+
json["error_type"] = errorType.rawValue
31+
}
3332

3433
case let .contextualUpdate(updateEvent):
3534
json["type"] = "contextual_update"
@@ -93,32 +92,19 @@ enum EventSerializer {
9392
}
9493

9594
// Conversation overrides
96-
if let conversationOverrides = config.conversationOverrides {
97-
var conversation: [String: Any] = [:]
98-
if conversationOverrides.textOnly {
99-
conversation["text_only"] = true
100-
}
101-
if let clientEvents = conversationOverrides.clientEvents {
102-
conversation["client_events"] = clientEvents
103-
}
104-
if !conversation.isEmpty {
105-
configOverride["conversation"] = conversation
106-
}
95+
if config.textOnly {
96+
configOverride["conversation"] = ["text_only": true]
10797
}
10898

10999
if !configOverride.isEmpty {
110100
json["conversation_config_override"] = configOverride
111101
}
112102

113-
if let customBody = config.customLlmExtraBody {
114-
json["custom_llm_extra_body"] = customBody
115-
}
116-
117103
if let dynamicVars = config.dynamicVariables {
118-
json["dynamic_variables"] = dynamicVars
104+
json["dynamic_variables"] = dynamicVars.mapValues(\.jsonObject)
119105
}
120106

121-
// Add source_info (equivalent to client in React Native)
107+
// Identify the SDK to the orchestrator.
122108
var sourceInfo: [String: Any] = [:]
123109
sourceInfo["source"] = "swift_sdk"
124110
sourceInfo["version"] = SDKVersion.version

Sources/ElevenLabs/Public/Conversation/Models/Message.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,31 @@ public struct Message: Identifiable, Sendable {
77
public let timestamp: Date
88
/// Server-assigned event id used for per-message operations like `sendFeedback`; `nil` for locally appended messages.
99
public let eventId: Int?
10+
/// Whether the message is still being assembled and may change. `true` while
11+
/// an agent message is streaming in from `agent_chat_response_part` chunks,
12+
/// or while a user message reflects an in-progress (tentative) transcript.
13+
/// It flips to `false` once the finalized agent response or user transcript
14+
/// arrives. Locally appended messages are always final (`false`).
15+
public let isPartial: Bool
1016

1117
public enum Role: Sendable {
1218
case user
1319
case agent
1420
}
21+
22+
init(
23+
id: String,
24+
role: Role,
25+
content: String,
26+
timestamp: Date,
27+
eventId: Int?,
28+
isPartial: Bool = false
29+
) {
30+
self.id = id
31+
self.role = role
32+
self.content = content
33+
self.timestamp = timestamp
34+
self.eventId = eventId
35+
self.isPartial = isPartial
36+
}
1537
}

0 commit comments

Comments
 (0)