Skip to content

Commit 22791f0

Browse files
renal128cursoragent
andcommitted
Replace messages with a response_id-keyed chatHistory.
event_id is turn-scoped, so tool loops collapsed distinct agent replies. Reconcile streams and corrections by response_id and keep messages plus tool calls in one stable list. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent eff4f67 commit 22791f0

19 files changed

Lines changed: 562 additions & 136 deletions

Documentation/Usage.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,16 @@ This document provides in-depth examples and advanced configuration options for
2626

2727
The `Conversation` object provides reactive `@Published` properties for seamless UI integration.
2828

29-
### Message Handling
29+
### Chat History
3030

31-
Monitor real-time transcriptions for both the agent and the user. The `messages` array is automatically updated.
31+
Observe messages and tool calls in one reconciled history.
3232

3333
```swift
34-
conversation.$messages
34+
conversation.$chatHistory
3535
.receive(on: DispatchQueue.main)
36-
.sink { messages in
37-
// messages are of type [Message]
38-
// Each message has role (.user or .agent) and content
36+
.sink { history in
37+
let messages = history.compactMap(\.message)
38+
let toolCalls = history.compactMap(\.toolCall)
3939
}
4040
.store(in: &cancellables)
4141
```
@@ -106,10 +106,11 @@ let conversation = try await ElevenLabs.startConversation(
106106
try await conversation.sendMessage("Hi! Tell me about the weather.")
107107

108108
// 3) Receive responses (reactive)
109-
conversation.$messages
109+
conversation.$chatHistory
110110
.receive(on: DispatchQueue.main)
111-
.sink { messages in
112-
guard let last = messages.last, last.role == .agent else { return }
111+
.compactMap { $0.compactMap(\.message).last }
112+
.sink { last in
113+
guard last.role == .agent else { return }
113114
print("Agent:", last.content)
114115
}
115116
.store(in: &cancellables)

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,16 @@ struct ChatView: View {
7676

7777
// Real-time transcriptions
7878
ScrollViewReader { proxy in
79+
let messages = conversation.chatHistory.compactMap(\.message)
7980
ScrollView {
80-
ForEach(conversation.messages) { msg in
81+
ForEach(messages) { msg in
8182
Text("**\(msg.role)**: \(msg.content)")
8283
.padding(8).background(Color.gray.opacity(0.1)).cornerRadius(8)
8384
.id(msg.id)
8485
}
8586
}
86-
.onChange(of: conversation.messages.count) { _ in
87-
proxy.scrollTo(conversation.messages.last?.id)
87+
.onChange(of: messages.count) { _ in
88+
proxy.scrollTo(messages.last?.id)
8889
}
8990
}
9091

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
struct ChatHistoryReconciler {
2+
private(set) var items: [any ChatHistoryItem] = []
3+
4+
mutating func appendUserMessage(_ content: String) {
5+
items.append(Message(
6+
role: .user,
7+
content: content,
8+
isFinal: true
9+
))
10+
}
11+
12+
mutating func receive(_ event: TentativeUserTranscriptEvent) {
13+
removeTentativeUserMessages()
14+
items.append(Message(
15+
role: .user,
16+
content: event.transcript,
17+
isFinal: false,
18+
eventId: event.eventId
19+
))
20+
}
21+
22+
mutating func receive(_ event: UserTranscriptEvent) {
23+
if let index = items.firstIndex(where: {
24+
$0.message?.role == .user
25+
&& $0.message?.eventId == event.eventId
26+
}) {
27+
updateMessage(at: index) {
28+
$0.content = event.transcript
29+
$0.isFinal = true
30+
}
31+
return
32+
}
33+
34+
removeTentativeUserMessages()
35+
items.append(Message(
36+
role: .user,
37+
content: event.transcript,
38+
isFinal: true,
39+
eventId: event.eventId
40+
))
41+
}
42+
43+
mutating func receive(_ event: AgentChatResponsePartEvent) {
44+
if let index = items.firstIndex(where: { $0.id == event.responseId }) {
45+
updateMessage(at: index) {
46+
guard !$0.isFinal else { return }
47+
$0.content += event.text
48+
}
49+
return
50+
}
51+
52+
items.append(Message(
53+
role: .agent,
54+
content: event.text,
55+
isFinal: false,
56+
eventId: event.eventId,
57+
responseId: event.responseId
58+
))
59+
}
60+
61+
mutating func receive(_ event: AgentResponseEvent) {
62+
upsertAgentMessage(
63+
responseId: event.responseId,
64+
eventId: event.eventId,
65+
content: event.response
66+
)
67+
}
68+
69+
mutating func receive(_ event: AgentResponseCorrectionEvent) {
70+
upsertAgentMessage(
71+
responseId: event.responseId,
72+
eventId: event.eventId,
73+
content: event.correctedAgentResponse
74+
)
75+
}
76+
77+
mutating func receive(_ event: AgentToolResponseEvent) {
78+
guard !items.contains(where: {
79+
$0.toolCall?.toolCallId == event.toolCallId
80+
}) else { return }
81+
items.append(ConversationToolCall(
82+
toolCallId: event.toolCallId,
83+
toolName: event.toolName
84+
))
85+
}
86+
87+
private mutating func upsertAgentMessage(responseId: String, eventId: Int, content: String) {
88+
if let index = items.firstIndex(where: { $0.id == responseId }) {
89+
updateMessage(at: index) {
90+
$0.content = content
91+
$0.isFinal = true
92+
$0.eventId = eventId
93+
}
94+
} else {
95+
items.append(Message(
96+
role: .agent,
97+
content: content,
98+
isFinal: true,
99+
eventId: eventId,
100+
responseId: responseId
101+
))
102+
}
103+
}
104+
105+
private mutating func updateMessage(at index: Int, update: (inout Message) -> Void) {
106+
guard var message = items[index] as? Message else { return }
107+
update(&message)
108+
items[index] = message
109+
}
110+
111+
private mutating func removeTentativeUserMessages() {
112+
items.removeAll {
113+
guard let message = $0.message else { return false }
114+
return message.role == .user
115+
&& !message.isFinal
116+
}
117+
}
118+
119+
}

Sources/ElevenLabs/Internal/Conversation/Conversation.swift

Lines changed: 15 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ final class Conversation: ObservableObject {
1414
// MARK: - State
1515

1616
@Published var state: ConversationState = .idle
17-
@Published var messages: [Message] = []
17+
@Published var chatHistory: [any ChatHistoryItem] = []
1818
@Published var agentState: AgentState = .listening
1919

20+
private var chatHistoryReconciler = ChatHistoryReconciler()
21+
2022
/// Stream of client tool calls that need to be executed by the app
2123
@Published var pendingToolCalls: [ClientToolCallEvent] = []
2224

@@ -275,7 +277,7 @@ final class Conversation: ObservableObject {
275277
}
276278
let event = OutgoingEvent.userMessage(UserMessageEvent(text: text))
277279
try await publish(event)
278-
appendMessage(role: .user, content: text)
280+
updateChatHistory { $0.appendUserMessage(text) }
279281
}
280282

281283
/// Mute or unmute the local microphone.
@@ -432,7 +434,7 @@ final class Conversation: ObservableObject {
432434
}
433435

434436
/// Tear down operational state when an active session ends.
435-
/// Preserves user-visible display state (messages, MCP activity, conversation
437+
/// Preserves user-visible display state (history, MCP activity, conversation
436438
/// metadata) so `ConversationClient` can keep the completed transcript visible.
437439
private func tearDownActiveSession() {
438440
cleanupTransientResources()
@@ -480,17 +482,17 @@ final class Conversation: ObservableObject {
480482
func handleIncomingEvent(_ event: IncomingEvent) async {
481483
switch event {
482484
case let .userTranscript(e):
483-
insertUserTranscript(content: e.transcript, eventId: e.eventId)
485+
updateChatHistory { $0.receive(e) }
484486
agentStateManager?.processSignal(.userTranscript)
485487
callbacks.onUserTranscript?(e.transcript, e.eventId)
486488

487489
case let .agentResponse(e):
488-
upsertAgentMessage(content: e.response, eventId: e.eventId)
490+
updateChatHistory { $0.receive(e) }
489491
agentStateManager?.processSignal(.agentResponse)
490492
callbacks.onAgentResponse?(e.response, e.eventId)
491493

492494
case let .agentResponseCorrection(correction):
493-
upsertAgentMessage(content: correction.correctedAgentResponse, eventId: correction.eventId)
495+
updateChatHistory { $0.receive(correction) }
494496
callbacks.onAgentResponseCorrection?(
495497
correction.originalAgentResponse,
496498
correction.correctedAgentResponse,
@@ -504,8 +506,7 @@ final class Conversation: ObservableObject {
504506
)
505507

506508
case let .agentChatResponsePart(e):
507-
let existing = messages.last(where: { $0.role == .agent && $0.eventId == e.eventId })?.content ?? ""
508-
upsertAgentMessage(content: existing + e.text, eventId: e.eventId)
509+
updateChatHistory { $0.receive(e) }
509510

510511
case let .audio(audioEvent):
511512
if let alignment = audioEvent.alignment {
@@ -534,6 +535,7 @@ final class Conversation: ObservableObject {
534535
callbacks.onVadScore?(vad.vadScore)
535536

536537
case let .agentToolResponse(toolResponse):
538+
updateChatHistory { $0.receive(toolResponse) }
537539
applyStateSignal(.agentToolResponse, fallback: .listening)
538540

539541
if toolResponse.toolName == "end_call" {
@@ -545,8 +547,8 @@ final class Conversation: ObservableObject {
545547
applyStateSignal(.agentToolRequest, fallback: .thinking)
546548
callbacks.onAgentToolRequest?(toolRequest)
547549

548-
case .tentativeUserTranscript:
549-
break
550+
case let .tentativeUserTranscript(transcript):
551+
updateChatHistory { $0.receive(transcript) }
550552

551553
case let .mcpToolCall(toolCall):
552554
if let index = mcpToolCalls.firstIndex(where: { $0.toolCallId == toolCall.toolCallId }) {
@@ -564,50 +566,9 @@ final class Conversation: ObservableObject {
564566
}
565567
}
566568

567-
// MARK: - Message Helpers
568-
569-
func appendMessage(role: Message.Role, content: String, eventId: Int? = nil) {
570-
messages.append(
571-
Message(
572-
id: UUID().uuidString,
573-
role: role,
574-
content: content,
575-
timestamp: Date(),
576-
eventId: eventId
577-
)
578-
)
579-
}
580-
581-
/// Inserts the user transcript before the agent message with the same `eventId`
582-
/// if one exists, since the agent's response may be received before the transcript.
583-
private func insertUserTranscript(content: String, eventId: Int) {
584-
let message = Message(
585-
id: UUID().uuidString,
586-
role: .user,
587-
content: content,
588-
timestamp: Date(),
589-
eventId: eventId
590-
)
591-
if let agentIdx = messages.firstIndex(where: { $0.role == .agent && $0.eventId == eventId }) {
592-
messages.insert(message, at: agentIdx)
593-
} else {
594-
messages.append(message)
595-
}
596-
}
597-
598-
private func upsertAgentMessage(content: String, eventId: Int) {
599-
if let idx = messages.lastIndex(where: { $0.role == .agent && $0.eventId == eventId }) {
600-
let existing = messages[idx]
601-
messages[idx] = Message(
602-
id: existing.id,
603-
role: .agent,
604-
content: content,
605-
timestamp: existing.timestamp,
606-
eventId: eventId
607-
)
608-
} else {
609-
appendMessage(role: .agent, content: content, eventId: eventId)
610-
}
569+
private func updateChatHistory(_ update: (inout ChatHistoryReconciler) -> Void) {
570+
update(&chatHistoryReconciler)
571+
chatHistory = chatHistoryReconciler.items
611572
}
612573
}
613574

Sources/ElevenLabs/Internal/Utilities/EventParser.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,28 @@ enum EventParser {
2727
case "agent_response":
2828
if let event = json["agent_response_event"] as? [String: Any],
2929
let response = event["agent_response"] as? String,
30-
let eventId = event["event_id"] as? Int
30+
let eventId = event["event_id"] as? Int,
31+
let responseId = event["response_id"] as? String
3132
{
32-
return .agentResponse(AgentResponseEvent(response: response, eventId: eventId))
33+
return .agentResponse(AgentResponseEvent(
34+
response: response,
35+
eventId: eventId,
36+
responseId: responseId
37+
))
3338
}
3439

3540
case "agent_response_correction":
3641
if let event = json["agent_response_correction_event"] as? [String: Any],
3742
let originalResponse = event["original_agent_response"] as? String,
3843
let correctedResponse = event["corrected_agent_response"] as? String,
39-
let eventId = event["event_id"] as? Int
44+
let eventId = event["event_id"] as? Int,
45+
let responseId = event["response_id"] as? String
4046
{
4147
return .agentResponseCorrection(AgentResponseCorrectionEvent(
4248
originalAgentResponse: originalResponse,
4349
correctedAgentResponse: correctedResponse,
44-
eventId: eventId
50+
eventId: eventId,
51+
responseId: responseId
4552
))
4653
}
4754

@@ -246,12 +253,18 @@ enum EventParser {
246253
case "agent_chat_response_part":
247254
if let event = json["text_response_part"] as? [String: Any],
248255
let text = event["text"] as? String,
249-
let eventId = event["event_id"] as? Int
256+
let eventId = event["event_id"] as? Int,
257+
let responseId = event["response_id"] as? String
250258
{
251259
let partTypeStr = event["type"] as? String ?? "delta"
252260
let partType = AgentChatResponsePartType(rawValue: partTypeStr) ?? .delta
253261
return .agentChatResponsePart(
254-
AgentChatResponsePartEvent(text: text, type: partType, eventId: eventId)
262+
AgentChatResponsePartEvent(
263+
text: text,
264+
type: partType,
265+
eventId: eventId,
266+
responseId: responseId
267+
)
255268
)
256269
}
257270

Sources/ElevenLabs/Public/Conversation/Events/IncomingEvents.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,15 @@ public struct TentativeUserTranscriptEvent: Sendable {
4545
public struct AgentResponseEvent: Sendable {
4646
public let response: String
4747
public let eventId: Int
48+
public let responseId: String
4849
}
4950

5051
/// Agent's response correction
5152
public struct AgentResponseCorrectionEvent: Sendable {
5253
public let originalAgentResponse: String
5354
public let correctedAgentResponse: String
5455
public let eventId: Int
56+
public let responseId: String
5557
}
5658

5759
/// Agent response metadata
@@ -64,6 +66,7 @@ public struct AgentChatResponsePartEvent: Sendable {
6466
public let text: String
6567
public let type: AgentChatResponsePartType
6668
public let eventId: Int
69+
public let responseId: String
6770
}
6871

6972
/// Audio alignment data showing character-level timing information

0 commit comments

Comments
 (0)