@@ -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}
0 commit comments