1use std::collections::{HashMap, HashSet};
2
3use utoipa::ToSchema;
4
5use crate::{
6 chatbot_conversation_message_messages::{self, ChatbotConversationMessageMessage, MessageRole},
7 chatbot_conversation_message_reasoning::{self, ChatbotConversationMessageReasoning},
8 chatbot_conversation_message_tool_calls::{self, ChatbotConversationMessageToolCall},
9 chatbot_conversation_message_tool_outputs::{self, ChatbotConversationMessageToolOutput},
10 error::missing_model_error,
11 prelude::*,
12};
13
14#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
15pub struct ChatbotConversationMessageRow {
16 pub id: Uuid,
17 pub created_at: DateTime<Utc>,
18 pub updated_at: DateTime<Utc>,
19 pub deleted_at: Option<DateTime<Utc>>,
20 pub conversation_id: Uuid,
21 pub order_number: i32,
22}
23
24#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
25#[serde(untagged)]
26pub enum Message {
27 Text(ChatbotConversationMessageMessage),
28 ToolCall(ChatbotConversationMessageToolCall),
29 ToolOutput(ChatbotConversationMessageToolOutput),
30 Reasoning(ChatbotConversationMessageReasoning),
31}
32
33#[derive(Clone, PartialEq, Deserialize, Serialize, Debug, ToSchema)]
34pub struct ChatbotConversationMessage {
35 pub id: Uuid,
36 pub created_at: DateTime<Utc>,
37 pub updated_at: DateTime<Utc>,
38 pub deleted_at: Option<DateTime<Utc>>,
39 pub conversation_id: Uuid,
40 pub order_number: i32,
41 pub message: Message,
42}
43
44impl Default for ChatbotConversationMessage {
45 fn default() -> Self {
46 Self {
47 id: Uuid::nil(),
48 created_at: Default::default(),
49 updated_at: Default::default(),
50 deleted_at: None,
51 conversation_id: Uuid::nil(),
52 order_number: Default::default(),
53 message: Message::Text(ChatbotConversationMessageMessage::default()),
54 }
55 }
56}
57
58impl ChatbotConversationMessage {
59 pub fn text(
66 conversation_id: Uuid,
67 message_role: MessageRole,
68 text: String,
69 used_tokens: i32,
70 response_id: Option<String>,
71 ) -> Self {
72 Self {
73 conversation_id,
74 message: Message::Text(ChatbotConversationMessageMessage {
75 text,
76 message_role,
77 message_is_complete: true,
78 used_tokens,
79 response_id,
80 ..Default::default()
81 }),
82 ..Default::default()
83 }
84 }
85
86 pub fn from_row(r: ChatbotConversationMessageRow, m: Message) -> Self {
87 ChatbotConversationMessage {
88 id: r.id,
89 created_at: r.created_at,
90 updated_at: r.updated_at,
91 deleted_at: r.deleted_at,
92 conversation_id: r.conversation_id,
93 order_number: r.order_number,
94 message: m,
95 }
96 }
97}
98
99async fn lock_conversation_for_order_number_allocation(
107 conn: &mut PgConnection,
108 conversation_id: Uuid,
109) -> ModelResult<()> {
110 sqlx::query!(
113 r#"
114SELECT id
115FROM chatbot_conversations
116WHERE id = $1
117 AND deleted_at IS NULL
118FOR NO KEY UPDATE
119 "#,
120 conversation_id
121 )
122 .fetch_optional(conn)
123 .await?
124 .ok_or_else(missing_model_error(
125 ModelErrorType::RecordNotFound,
126 format!("Chatbot conversation {conversation_id} does not exist or has been deleted"),
127 ))?;
128 Ok(())
129}
130
131pub async fn insert(
137 conn: &mut PgConnection,
138 input: ChatbotConversationMessage,
139) -> ModelResult<ChatbotConversationMessage> {
140 let mut tx = conn.begin().await?;
141 lock_conversation_for_order_number_allocation(&mut tx, input.conversation_id).await?;
142 let res = insert_locked(&mut tx, input).await?;
143 tx.commit().await?;
144 Ok(res)
145}
146
147async fn insert_locked(
150 conn: &mut PgConnection,
151 input: ChatbotConversationMessage,
152) -> ModelResult<ChatbotConversationMessage> {
153 let msg = sqlx::query_as!(
154 ChatbotConversationMessageRow,
155 r#"
156INSERT INTO chatbot_conversation_messages (conversation_id, order_number)
157VALUES (
158 $1,
159 COALESCE((
160 SELECT order_number
161 FROM chatbot_conversation_messages
162 WHERE conversation_id = $1
163 AND deleted_at IS NULL
164 ORDER BY order_number DESC
165 LIMIT 1
166 ), 0) + 1
167 )
168RETURNING *
169 "#,
170 input.conversation_id,
171 )
172 .fetch_one(&mut *conn)
173 .await?;
174
175 let inner = match input.message {
176 Message::Text(message) => {
177 let res = chatbot_conversation_message_messages::insert(conn, message, msg.id).await?;
178 Message::Text(res)
179 }
180 Message::ToolCall(tool_call) => {
181 let res =
182 chatbot_conversation_message_tool_calls::insert(conn, tool_call, msg.id).await?;
183 Message::ToolCall(res)
184 }
185 Message::ToolOutput(tool_output) => {
186 let res = chatbot_conversation_message_tool_outputs::insert(conn, tool_output, msg.id)
187 .await?;
188 Message::ToolOutput(res)
189 }
190 Message::Reasoning(reasoning) => {
191 let res =
192 chatbot_conversation_message_reasoning::insert(conn, reasoning, msg.id).await?;
193 Message::Reasoning(res)
194 }
195 };
196
197 Ok(ChatbotConversationMessage::from_row(msg, inner))
198}
199
200pub async fn insert_for_conversation_user_and_configuration(
202 conn: &mut PgConnection,
203 input: ChatbotConversationMessage,
204 user_id: Option<Uuid>,
205 anonymous_token: Option<String>,
206 chatbot_configuration_id: Uuid,
207) -> ModelResult<ChatbotConversationMessage> {
208 if let (Some(_user_id), Some(_anonymous_token)) = (&user_id, &anonymous_token) {
209 return Err(model_err!(
210 InvalidRequest,
211 "User ID and anonymous token cannot both be present".to_string()
212 ));
213 }
214 let mut tx = conn.begin().await?;
215
216 sqlx::query!(
219 r#"
220SELECT id
221FROM chatbot_conversations
222WHERE id = $1
223 AND (
224 user_id = $2
225 OR anonymous_token = $3
226 )
227 AND chatbot_configuration_id = $4
228 AND deleted_at IS NULL
229FOR NO KEY UPDATE
230 "#,
231 input.conversation_id,
232 user_id,
233 anonymous_token,
234 chatbot_configuration_id
235 )
236 .fetch_one(&mut *tx)
237 .await?;
238
239 let msg = sqlx::query_as!(
240 ChatbotConversationMessageRow,
241 r#"
242INSERT INTO chatbot_conversation_messages (
243 conversation_id,
244 order_number
245)
246VALUES (
247 $1,
248 COALESCE((
249 SELECT order_number
250 FROM chatbot_conversation_messages
251 WHERE conversation_id = $1
252 AND deleted_at IS NULL
253 ORDER BY order_number DESC
254 LIMIT 1
255 ), 0) + 1
256)
257RETURNING *
258 "#,
259 input.conversation_id,
260 )
261 .fetch_one(&mut *tx)
262 .await?;
263
264 let inner = match input.message {
265 Message::Text(message) => {
266 let res =
267 chatbot_conversation_message_messages::insert(&mut tx, message, msg.id).await?;
268 Message::Text(res)
269 }
270 Message::ToolCall(tool_call) => {
271 let res =
272 chatbot_conversation_message_tool_calls::insert(&mut tx, tool_call, msg.id).await?;
273 Message::ToolCall(res)
274 }
275 Message::ToolOutput(tool_output) => {
276 let res =
277 chatbot_conversation_message_tool_outputs::insert(&mut tx, tool_output, msg.id)
278 .await?;
279 Message::ToolOutput(res)
280 }
281 Message::Reasoning(reasoning) => {
282 let res =
283 chatbot_conversation_message_reasoning::insert(&mut tx, reasoning, msg.id).await?;
284 Message::Reasoning(res)
285 }
286 };
287
288 let res = ChatbotConversationMessage::from_row(msg, inner);
289 tx.commit().await?;
290 Ok(res)
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum ReasoningPayload {
296 Include,
298 Omit,
301}
302
303pub async fn get_by_conversation_id(
304 conn: &mut PgConnection,
305 conversation_id: Uuid,
306) -> ModelResult<Vec<ChatbotConversationMessage>> {
307 get_conversation_messages(conn, conversation_id, ReasoningPayload::Include).await
308}
309
310pub async fn get_by_conversation_id_for_display(
312 conn: &mut PgConnection,
313 conversation_id: Uuid,
314) -> ModelResult<Vec<ChatbotConversationMessage>> {
315 get_conversation_messages(conn, conversation_id, ReasoningPayload::Omit).await
316}
317
318async fn get_conversation_messages(
319 conn: &mut PgConnection,
320 conversation_id: Uuid,
321 reasoning_payload: ReasoningPayload,
322) -> ModelResult<Vec<ChatbotConversationMessage>> {
323 let mut tx = conn.begin().await?;
324 let rows: Vec<ChatbotConversationMessageRow> = sqlx::query_as!(
325 ChatbotConversationMessageRow,
326 r#"
327SELECT *
328FROM chatbot_conversation_messages
329WHERE conversation_id = $1
330AND deleted_at IS NULL
331ORDER BY order_number
332 "#,
333 conversation_id
334 )
335 .fetch_all(&mut *tx)
336 .await?;
337 let message_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
338 let mut inner_messages = get_inner_messages(&mut tx, &message_ids, reasoning_payload).await?;
339 tx.commit().await?;
340
341 rows.into_iter()
342 .map(|row| {
343 let inner_message = inner_messages
344 .remove(&row.id)
345 .ok_or_else(missing_inner_message_error())?;
346 Ok(ChatbotConversationMessage::from_row(row, inner_message))
347 })
348 .collect()
349}
350
351pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<ChatbotConversationMessage> {
352 let mut tx = conn.begin().await?;
353
354 let row = sqlx::query_as!(
355 ChatbotConversationMessageRow,
356 r#"
357UPDATE chatbot_conversation_messages
358SET deleted_at = NOW()
359WHERE id = $1
360 AND deleted_at IS NULL
361RETURNING *
362 "#,
363 id
364 )
365 .fetch_one(&mut *tx)
366 .await?;
367
368 let child = delete_message_fields(&mut tx, row.id).await?;
370
371 let res = ChatbotConversationMessage::from_row(row, child);
372 tx.commit().await?;
373 Ok(res)
374}
375
376pub async fn get_latest_developer_message_text(
383 conn: &mut PgConnection,
384 conversation_id: Uuid,
385) -> ModelResult<Option<String>> {
386 let res = sqlx::query_scalar!(
387 r#"
388SELECT ccmm.text
389FROM chatbot_conversation_message_messages AS ccmm
390 JOIN chatbot_conversation_messages AS ccm ON ccm.id = ccmm.chatbot_conversation_message_id
391WHERE ccm.conversation_id = $1
392 AND ccmm.message_role = 'developer'
393 AND ccmm.deleted_at IS NULL
394 AND ccm.deleted_at IS NULL
395ORDER BY ccm.order_number DESC
396LIMIT 1
397 "#,
398 conversation_id
399 )
400 .fetch_optional(conn)
401 .await?;
402 Ok(res)
403}
404
405pub enum UnansweredToolCallScope<'a> {
407 OwnTurn(&'a [String]),
412 AnyTurnOlderThan(DateTime<Utc>),
415}
416
417pub async fn answer_hanging_tool_call_messages_for_conversation(
443 conn: &mut PgConnection,
444 conversation_id: Uuid,
445 scope: UnansweredToolCallScope<'_>,
446 output_text: &str,
447) -> ModelResult<Vec<ChatbotConversationMessageToolCall>> {
448 let needs_answering = |tool_call: &ChatbotConversationMessageToolCall| {
449 !tool_call.tool_kind.is_answered_by_client()
450 && match scope {
451 UnansweredToolCallScope::OwnTurn(response_ids) => {
452 response_ids.contains(&tool_call.response_id)
453 }
454 UnansweredToolCallScope::AnyTurnOlderThan(cutoff) => tool_call.created_at < cutoff,
455 }
456 };
457
458 let unanswered =
459 chatbot_conversation_message_tool_calls::get_unanswered_tool_calls_for_conversation(
460 conn,
461 conversation_id,
462 )
463 .await?;
464 if !unanswered.iter().any(&needs_answering) {
465 return Ok(unanswered);
466 }
467
468 let mut tx = conn.begin().await?;
469 lock_conversation_for_order_number_allocation(&mut tx, conversation_id).await?;
470 let res = answer_unanswered_tool_calls(&mut tx, conversation_id, output_text, needs_answering)
471 .await?;
472 tx.commit().await?;
473 Ok(res)
474}
475
476async fn answer_unanswered_tool_calls(
483 conn: &mut PgConnection,
484 conversation_id: Uuid,
485 output_text: &str,
486 needs_answering: impl Fn(&ChatbotConversationMessageToolCall) -> bool,
487) -> ModelResult<Vec<ChatbotConversationMessageToolCall>> {
488 let unanswered =
489 chatbot_conversation_message_tool_calls::get_unanswered_tool_calls_for_conversation(
490 conn,
491 conversation_id,
492 )
493 .await?;
494
495 let (to_answer, left_alone): (Vec<_>, Vec<_>) =
496 unanswered.into_iter().partition(&needs_answering);
497 for tool_call in to_answer {
498 insert_locked(
499 conn,
500 tool_call_output_message(conversation_id, tool_call, output_text.to_string(), None),
501 )
502 .await?;
503 }
504 Ok(left_alone)
505}
506
507pub async fn abort_pending_client_tool_calls(
517 conn: &mut PgConnection,
518 conversation_id: Uuid,
519 output_text: &str,
520) -> ModelResult<()> {
521 lock_conversation_for_order_number_allocation(conn, conversation_id).await?;
522 answer_unanswered_tool_calls(conn, conversation_id, output_text, |tool_call| {
523 tool_call.tool_kind.is_answered_by_client()
524 })
525 .await?;
526 Ok(())
527}
528
529pub fn turn_is_suspended(messages: &[ChatbotConversationMessage]) -> bool {
535 !waiting_client_tool_call_ids(messages).is_empty()
536}
537
538pub fn waiting_client_tool_call_ids(messages: &[ChatbotConversationMessage]) -> Vec<&str> {
545 let answered: HashSet<&str> = messages
546 .iter()
547 .filter_map(|message| match &message.message {
548 Message::ToolOutput(output) => Some(output.tool_call_id.as_str()),
549 _ => None,
550 })
551 .collect();
552
553 messages
554 .iter()
555 .filter_map(|message| match &message.message {
556 Message::ToolCall(call)
557 if call.tool_kind.is_answered_by_client()
558 && !answered.contains(call.tool_call_id.as_str()) =>
559 {
560 Some(call.tool_call_id.as_str())
561 }
562 _ => None,
563 })
564 .collect()
565}
566
567#[derive(Debug, Clone, PartialEq)]
569pub struct ClientToolAnswerOutcome {
570 pub answer: ChatbotConversationMessage,
571 pub turn_can_resume: bool,
574}
575
576pub async fn answer_client_tool_call(
590 conn: &mut PgConnection,
591 conversation_id: Uuid,
592 tool_call_id: &str,
593 output: String,
594 client_answer: Option<serde_json::Value>,
595) -> ModelResult<ClientToolAnswerOutcome> {
596 let mut tx = conn.begin().await?;
597 lock_conversation_for_order_number_allocation(&mut tx, conversation_id).await?;
598
599 let mut unanswered =
600 chatbot_conversation_message_tool_calls::get_unanswered_tool_calls_for_conversation(
601 &mut tx,
602 conversation_id,
603 )
604 .await?;
605
606 let (tool_call, is_unanswered) = match unanswered
609 .iter()
610 .position(|call| call.tool_call_id == tool_call_id)
611 {
612 Some(index) => (unanswered.swap_remove(index), true),
613 None => {
614 match chatbot_conversation_message_tool_calls::get_by_conversation_and_tool_call_id(
615 &mut tx,
616 conversation_id,
617 tool_call_id,
618 )
619 .await?
620 {
621 Some(call) => (call, false),
622 None => {
623 tx.rollback().await?;
624 return Err(model_err!(
625 RecordNotFound,
626 format!(
627 "Chatbot conversation {conversation_id} has no tool call {tool_call_id}"
628 )
629 ));
630 }
631 }
632 }
633 };
634
635 if !tool_call.tool_kind.is_answered_by_client() {
636 tx.rollback().await?;
637 return Err(model_err!(
638 InvalidRequest,
639 format!("Tool call {tool_call_id} is not answered by the client")
640 ));
641 }
642 if !is_unanswered {
643 tx.rollback().await?;
644 return Err(model_err!(
645 InvalidRequest,
646 format!("Tool call {tool_call_id} has already been answered")
647 ));
648 }
649
650 let turn_can_resume = !unanswered.iter().any(|call| {
654 call.tool_kind.is_answered_by_client() && call.response_id == tool_call.response_id
655 });
656
657 let answer = insert_locked(
658 &mut tx,
659 tool_call_output_message(conversation_id, tool_call, output, client_answer),
660 )
661 .await?;
662
663 tx.commit().await?;
664 Ok(ClientToolAnswerOutcome {
665 answer,
666 turn_can_resume,
667 })
668}
669
670fn tool_call_output_message(
674 conversation_id: Uuid,
675 tool_call: ChatbotConversationMessageToolCall,
676 output: String,
677 client_answer: Option<serde_json::Value>,
678) -> ChatbotConversationMessage {
679 ChatbotConversationMessage {
680 conversation_id,
681 message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
682 output,
683 client_answer,
684 tool_call_id: tool_call.tool_call_id,
685 tool_kind: tool_call.tool_kind,
686 response_id: tool_call.response_id,
687 ..Default::default()
688 }),
689 ..Default::default()
690 }
691}
692
693pub async fn update(
694 conn: &mut PgConnection,
695 id: Uuid,
696 text: &str,
697 message_is_complete: bool,
698 used_tokens: i32,
699) -> ModelResult<ChatbotConversationMessage> {
700 let mut tx = conn.begin().await?;
701
702 let row = sqlx::query_as!(
703 ChatbotConversationMessageRow,
704 r#"
705UPDATE chatbot_conversation_messages
706SET updated_at = NOW()
707WHERE id = $1
708 AND deleted_at IS NULL
709RETURNING *
710 "#,
711 id
712 )
713 .fetch_one(&mut *tx)
714 .await?;
715
716 let child = chatbot_conversation_message_messages::update(
718 &mut tx,
719 row.id,
720 text,
721 message_is_complete,
722 used_tokens,
723 )
724 .await?;
725
726 let res = ChatbotConversationMessage::from_row(row, Message::Text(child));
727 tx.commit().await?;
728
729 Ok(res)
730}
731
732pub async fn get_message_fields(conn: &mut PgConnection, message_id: Uuid) -> ModelResult<Message> {
734 get_inner_messages(conn, &[message_id], ReasoningPayload::Include)
735 .await?
736 .remove(&message_id)
737 .ok_or_else(missing_inner_message_error())
738}
739
740async fn get_inner_messages(
746 conn: &mut PgConnection,
747 message_ids: &[Uuid],
748 reasoning_payload: ReasoningPayload,
749) -> ModelResult<HashMap<Uuid, Message>> {
750 let mut res = HashMap::with_capacity(message_ids.len());
751 if message_ids.is_empty() {
752 return Ok(res);
753 }
754
755 for text in get_text_messages(&mut *conn, message_ids).await? {
756 res.entry(text.chatbot_conversation_message_id)
757 .or_insert(Message::Text(text));
758 }
759 for tool_call in get_tool_calls(&mut *conn, message_ids).await? {
760 res.entry(tool_call.chatbot_conversation_message_id)
761 .or_insert(Message::ToolCall(tool_call));
762 }
763 for tool_output in get_tool_outputs(&mut *conn, message_ids).await? {
764 res.entry(tool_output.chatbot_conversation_message_id)
765 .or_insert(Message::ToolOutput(tool_output));
766 }
767 for reasoning in get_reasonings(&mut *conn, message_ids, reasoning_payload).await? {
768 res.entry(reasoning.chatbot_conversation_message_id)
769 .or_insert(Message::Reasoning(reasoning));
770 }
771 Ok(res)
772}
773
774fn missing_inner_message_error() -> impl FnOnce() -> ModelError {
775 missing_model_error(
776 ModelErrorType::RecordNotFound,
777 "No inner message found for this ChatbotConversationMessage",
778 )
779}
780
781async fn get_text_messages(
782 conn: &mut PgConnection,
783 message_ids: &[Uuid],
784) -> ModelResult<Vec<ChatbotConversationMessageMessage>> {
785 let res = sqlx::query_as!(
786 ChatbotConversationMessageMessage,
787 r#"
788SELECT
789 id,
790 created_at,
791 updated_at,
792 deleted_at,
793 chatbot_conversation_message_id,
794 text,
795 message_role as "message_role: MessageRole",
796 message_is_complete,
797 used_tokens,
798 response_id
799FROM chatbot_conversation_message_messages
800WHERE chatbot_conversation_message_id = ANY($1)
801 AND deleted_at IS NULL
802 "#,
803 message_ids
804 )
805 .fetch_all(conn)
806 .await?;
807 Ok(res)
808}
809
810async fn get_tool_calls(
811 conn: &mut PgConnection,
812 message_ids: &[Uuid],
813) -> ModelResult<Vec<ChatbotConversationMessageToolCall>> {
814 let res = sqlx::query_as!(
815 ChatbotConversationMessageToolCall,
816 r#"
817SELECT *
818FROM chatbot_conversation_message_tool_calls
819WHERE chatbot_conversation_message_id = ANY($1)
820 AND deleted_at IS NULL
821 "#,
822 message_ids
823 )
824 .fetch_all(conn)
825 .await?;
826 Ok(res)
827}
828
829async fn get_tool_outputs(
830 conn: &mut PgConnection,
831 message_ids: &[Uuid],
832) -> ModelResult<Vec<ChatbotConversationMessageToolOutput>> {
833 let res = sqlx::query_as!(
834 ChatbotConversationMessageToolOutput,
835 r#"
836SELECT *
837FROM chatbot_conversation_message_tool_outputs
838WHERE chatbot_conversation_message_id = ANY($1)
839 AND deleted_at IS NULL
840 "#,
841 message_ids
842 )
843 .fetch_all(conn)
844 .await?;
845 Ok(res)
846}
847
848async fn get_reasonings(
849 conn: &mut PgConnection,
850 message_ids: &[Uuid],
851 reasoning_payload: ReasoningPayload,
852) -> ModelResult<Vec<ChatbotConversationMessageReasoning>> {
853 let res = match reasoning_payload {
854 ReasoningPayload::Include => {
855 sqlx::query_as!(
856 ChatbotConversationMessageReasoning,
857 r#"
858SELECT *
859FROM chatbot_conversation_message_reasoning
860WHERE chatbot_conversation_message_id = ANY($1)
861 AND deleted_at IS NULL
862 "#,
863 message_ids
864 )
865 .fetch_all(conn)
866 .await?
867 }
868 ReasoningPayload::Omit => {
869 sqlx::query_as!(
870 ChatbotConversationMessageReasoning,
871 r#"
872SELECT id,
873 chatbot_conversation_message_id,
874 created_at,
875 updated_at,
876 deleted_at,
877 summary,
878 reasoning_id,
879 response_id,
880 NULL::TEXT AS encrypted_content
881FROM chatbot_conversation_message_reasoning
882WHERE chatbot_conversation_message_id = ANY($1)
883 AND deleted_at IS NULL
884 "#,
885 message_ids
886 )
887 .fetch_all(conn)
888 .await?
889 }
890 };
891 Ok(res)
892}
893
894pub async fn delete_message_fields(
895 conn: &mut PgConnection,
896 message_id: Uuid,
897) -> ModelResult<Message> {
898 if let Some(message) =
899 chatbot_conversation_message_messages::get_by_message_id(conn, message_id).await?
900 {
901 let res = chatbot_conversation_message_messages::delete(conn, message.id).await?;
902 Ok(Message::Text(res))
903 } else if let Some(tool_call) =
904 chatbot_conversation_message_tool_calls::get_by_message_id(conn, message_id).await?
905 {
906 let res = chatbot_conversation_message_tool_calls::delete(conn, tool_call.id).await?;
907 Ok(Message::ToolCall(res))
908 } else if let Some(tool_output) =
909 chatbot_conversation_message_tool_outputs::get_by_message_id(conn, message_id).await?
910 {
911 let res = chatbot_conversation_message_tool_outputs::delete(conn, tool_output.id).await?;
912 Ok(Message::ToolOutput(res))
913 } else if let Some(reasoning) =
914 chatbot_conversation_message_reasoning::get_by_message_id(conn, message_id).await?
915 {
916 let res = chatbot_conversation_message_reasoning::delete(conn, reasoning.id).await?;
917 Ok(Message::Reasoning(res))
918 } else {
919 Err(ModelError::new(
920 ModelErrorType::RecordNotFound,
921 "No inner message found for this ChatbotConversationMessage",
922 None,
923 ))
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use crate::{
931 chatbot_conversation_message_messages::MessageRole,
932 chatbot_conversation_message_tool_calls::ToolKind, chatbot_conversations, test_helper::*,
933 };
934
935 const RESPONSE_ID: &str = "resp_test";
938
939 fn own_turn_response_ids() -> Vec<String> {
941 vec![RESPONSE_ID.to_string()]
942 }
943
944 fn user_message(conversation_id: Uuid, text: &str) -> ChatbotConversationMessage {
945 chatbot_text_message(conversation_id, MessageRole::User, text, None)
946 }
947
948 fn developer_message(conversation_id: Uuid, text: &str) -> ChatbotConversationMessage {
949 chatbot_text_message(
950 conversation_id,
951 MessageRole::Developer,
952 text,
953 Some("page-context"),
954 )
955 }
956
957 fn tool_call_message(
958 conversation_id: Uuid,
959 tool_call_id: &str,
960 tool_kind: ToolKind,
961 ) -> ChatbotConversationMessage {
962 chatbot_tool_call_message(conversation_id, tool_call_id, tool_kind, RESPONSE_ID)
963 }
964
965 fn message_summary(messages: &[ChatbotConversationMessage]) -> Vec<String> {
967 messages
968 .iter()
969 .map(|message| match &message.message {
970 Message::ToolCall(call) => format!("call {}", call.tool_call_id),
971 Message::ToolOutput(output) => format!("output {}", output.tool_call_id),
972 Message::Text(text) => format!("text {}", text.text),
973 Message::Reasoning(..) => "reasoning".to_string(),
974 })
975 .collect()
976 }
977
978 #[tokio::test]
979 async fn numbers_messages_in_insertion_order() {
980 insert_data!(:tx);
981 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
982
983 let first = insert(tx.as_mut(), user_message(conversation, "first"))
984 .await
985 .unwrap();
986 let second = insert(tx.as_mut(), user_message(conversation, "second"))
987 .await
988 .unwrap();
989
990 assert_eq!((first.order_number, second.order_number), (1, 2));
991 }
992
993 #[tokio::test]
994 async fn refuses_to_add_a_message_to_a_deleted_conversation() {
995 insert_data!(:tx);
996 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
997 crate::chatbot_conversations::delete(tx.as_mut(), conversation)
998 .await
999 .unwrap();
1000
1001 let error = insert(tx.as_mut(), user_message(conversation, "hello"))
1002 .await
1003 .expect_err("adding a message to a deleted conversation must fail");
1004
1005 assert!(matches!(error.error_type(), ModelErrorType::RecordNotFound));
1006 }
1007
1008 #[tokio::test]
1011 async fn answers_a_hanging_tool_call_before_the_next_message() {
1012 insert_data!(:tx);
1013 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1014 insert(
1015 tx.as_mut(),
1016 tool_call_message(conversation, "call_1", ToolKind::Function),
1017 )
1018 .await
1019 .unwrap();
1020
1021 answer_hanging_tool_call_messages_for_conversation(
1022 tx.as_mut(),
1023 conversation,
1024 UnansweredToolCallScope::OwnTurn(&own_turn_response_ids()),
1025 "aborted",
1026 )
1027 .await
1028 .unwrap();
1029 insert(tx.as_mut(), user_message(conversation, "hello again"))
1030 .await
1031 .unwrap();
1032
1033 let hanging =
1034 chatbot_conversation_message_tool_calls::get_unanswered_tool_calls_for_conversation(
1035 tx.as_mut(),
1036 conversation,
1037 )
1038 .await
1039 .unwrap();
1040 assert!(hanging.is_empty());
1041
1042 let messages = get_by_conversation_id(tx.as_mut(), conversation)
1043 .await
1044 .unwrap();
1045 assert_eq!(
1046 message_summary(&messages),
1047 vec!["call call_1", "output call_1", "text hello again"]
1048 );
1049 }
1050
1051 #[tokio::test]
1056 async fn the_latest_developer_message_is_the_newest_one_of_that_conversation() {
1057 insert_data!(:tx);
1058 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1059
1060 assert_eq!(
1061 get_latest_developer_message_text(tx.as_mut(), conversation)
1062 .await
1063 .unwrap(),
1064 None
1065 );
1066
1067 for message in [
1068 developer_message(conversation, "reading page one"),
1069 user_message(conversation, "what is this"),
1070 developer_message(conversation, "reading page two"),
1071 user_message(conversation, "and this"),
1072 ] {
1073 insert(tx.as_mut(), message).await.unwrap();
1074 }
1075
1076 assert_eq!(
1077 get_latest_developer_message_text(tx.as_mut(), conversation)
1078 .await
1079 .unwrap()
1080 .as_deref(),
1081 Some("reading page two")
1082 );
1083
1084 let (_other_configuration, other_conversation) =
1085 insert_chatbot_conversation(tx.as_mut()).await;
1086 insert(
1087 tx.as_mut(),
1088 developer_message(other_conversation, "reading somewhere else"),
1089 )
1090 .await
1091 .unwrap();
1092 assert_eq!(
1093 get_latest_developer_message_text(tx.as_mut(), conversation)
1094 .await
1095 .unwrap()
1096 .as_deref(),
1097 Some("reading page two")
1098 );
1099 }
1100
1101 #[tokio::test]
1105 async fn the_sweep_leaves_a_call_newer_than_the_cutoff_to_the_turn_that_made_it() {
1106 insert_data!(:tx);
1107 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1108 insert(
1109 tx.as_mut(),
1110 tool_call_message(conversation, "call_in_flight", ToolKind::Function),
1111 )
1112 .await
1113 .unwrap();
1114
1115 let cutoff = Utc::now() - chrono::Duration::minutes(10);
1116 answer_hanging_tool_call_messages_for_conversation(
1117 tx.as_mut(),
1118 conversation,
1119 UnansweredToolCallScope::AnyTurnOlderThan(cutoff),
1120 "aborted",
1121 )
1122 .await
1123 .unwrap();
1124
1125 let messages = get_by_conversation_id(tx.as_mut(), conversation)
1126 .await
1127 .unwrap();
1128 assert_eq!(message_summary(&messages), vec!["call call_in_flight"]);
1129
1130 answer_hanging_tool_call_messages_for_conversation(
1133 tx.as_mut(),
1134 conversation,
1135 UnansweredToolCallScope::AnyTurnOlderThan(Utc::now() + chrono::Duration::minutes(10)),
1136 "aborted",
1137 )
1138 .await
1139 .unwrap();
1140
1141 let messages = get_by_conversation_id(tx.as_mut(), conversation)
1142 .await
1143 .unwrap();
1144 assert_eq!(
1145 message_summary(&messages),
1146 vec!["call call_in_flight", "output call_in_flight"]
1147 );
1148 }
1149
1150 #[tokio::test]
1153 async fn the_sweep_repairs_an_abandoned_call_and_leaves_a_waiting_one_alone() {
1154 insert_data!(:tx);
1155 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1156 insert(
1157 tx.as_mut(),
1158 tool_call_message(conversation, "call_abandoned", ToolKind::Function),
1159 )
1160 .await
1161 .unwrap();
1162 insert(
1163 tx.as_mut(),
1164 tool_call_message(conversation, "call_waiting", ToolKind::ClientTool),
1165 )
1166 .await
1167 .unwrap();
1168
1169 answer_hanging_tool_call_messages_for_conversation(
1170 tx.as_mut(),
1171 conversation,
1172 UnansweredToolCallScope::OwnTurn(&own_turn_response_ids()),
1173 "aborted",
1174 )
1175 .await
1176 .unwrap();
1177
1178 let messages = get_by_conversation_id(tx.as_mut(), conversation)
1179 .await
1180 .unwrap();
1181 assert_eq!(
1182 waiting_client_tool_call_ids(&messages),
1183 vec!["call_waiting"]
1184 );
1185 assert_eq!(
1186 message_summary(&messages),
1187 vec![
1188 "call call_abandoned",
1189 "call call_waiting",
1190 "output call_abandoned"
1191 ]
1192 );
1193 }
1194
1195 #[tokio::test]
1198 async fn a_new_message_aborts_a_pending_client_tool_call() {
1199 insert_data!(:tx);
1200 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1201 insert(
1202 tx.as_mut(),
1203 tool_call_message(conversation, "call_1", ToolKind::ClientTool),
1204 )
1205 .await
1206 .unwrap();
1207
1208 abort_pending_client_tool_calls(tx.as_mut(), conversation, "the user moved on")
1209 .await
1210 .unwrap();
1211 insert(tx.as_mut(), user_message(conversation, "never mind"))
1212 .await
1213 .unwrap();
1214
1215 let messages = get_by_conversation_id(tx.as_mut(), conversation)
1216 .await
1217 .unwrap();
1218 assert!(waiting_client_tool_call_ids(&messages).is_empty());
1219 assert_eq!(
1220 message_summary(&messages),
1221 vec!["call call_1", "output call_1", "text never mind"]
1222 );
1223 let Some(Message::ToolOutput(output)) =
1224 messages.get(1).map(|message| &message.message).cloned()
1225 else {
1226 panic!("the aborted call is answered by a tool output");
1227 };
1228 assert_eq!(output.tool_kind, ToolKind::ClientTool);
1229 assert_eq!(output.response_id, RESPONSE_ID);
1230 assert_eq!(output.output, "the user moved on");
1231 }
1232
1233 #[tokio::test]
1236 async fn only_the_last_answer_of_a_round_resumes_the_turn() {
1237 insert_data!(:tx);
1238 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1239 for tool_call_id in ["call_1", "call_2"] {
1240 insert(
1241 tx.as_mut(),
1242 tool_call_message(conversation, tool_call_id, ToolKind::ClientTool),
1243 )
1244 .await
1245 .unwrap();
1246 }
1247
1248 let client_answer = serde_json::json!({ "choice_index": 1 });
1249 let first = answer_client_tool_call(
1250 tx.as_mut(),
1251 conversation,
1252 "call_1",
1253 "first".to_string(),
1254 Some(client_answer.clone()),
1255 )
1256 .await
1257 .unwrap();
1258 let second = answer_client_tool_call(
1259 tx.as_mut(),
1260 conversation,
1261 "call_2",
1262 "second".to_string(),
1263 None,
1264 )
1265 .await
1266 .unwrap();
1267
1268 assert!(!first.turn_can_resume);
1269 assert!(second.turn_can_resume);
1270 let Message::ToolOutput(output) = first.answer.message else {
1271 panic!("an answer is a tool output");
1272 };
1273 assert_eq!(output.response_id, RESPONSE_ID);
1274 assert_eq!(output.tool_kind, ToolKind::ClientTool);
1275
1276 let messages = get_by_conversation_id(tx.as_mut(), conversation)
1277 .await
1278 .unwrap();
1279 let Some(Message::ToolOutput(stored)) =
1280 messages.get(2).map(|message| &message.message).cloned()
1281 else {
1282 panic!("the first answer is a tool output");
1283 };
1284 assert_eq!(stored.client_answer, Some(client_answer));
1285 }
1286
1287 #[tokio::test]
1290 async fn refuses_answers_the_conversation_has_no_room_for() {
1291 insert_data!(:tx);
1292 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1293 let (_other_configuration, other_conversation) =
1294 insert_chatbot_conversation(tx.as_mut()).await;
1295 insert(
1296 tx.as_mut(),
1297 tool_call_message(conversation, "call_client", ToolKind::ClientTool),
1298 )
1299 .await
1300 .unwrap();
1301 insert(
1302 tx.as_mut(),
1303 tool_call_message(conversation, "call_function", ToolKind::Function),
1304 )
1305 .await
1306 .unwrap();
1307 insert(
1308 tx.as_mut(),
1309 tool_call_message(other_conversation, "call_elsewhere", ToolKind::ClientTool),
1310 )
1311 .await
1312 .unwrap();
1313 answer_client_tool_call(
1314 tx.as_mut(),
1315 conversation,
1316 "call_client",
1317 "done".to_string(),
1318 None,
1319 )
1320 .await
1321 .unwrap();
1322
1323 for (tool_call_id, expected) in [
1324 ("call_unknown", ModelErrorType::RecordNotFound),
1325 ("call_elsewhere", ModelErrorType::RecordNotFound),
1326 ("call_function", ModelErrorType::InvalidRequest),
1327 ("call_client", ModelErrorType::InvalidRequest),
1328 ] {
1329 let error = answer_client_tool_call(
1330 tx.as_mut(),
1331 conversation,
1332 tool_call_id,
1333 "again".to_string(),
1334 None,
1335 )
1336 .await
1337 .expect_err("the answer must be refused");
1338 assert_eq!(
1339 std::mem::discriminant(error.error_type()),
1340 std::mem::discriminant(&expected),
1341 "answering {tool_call_id}: {error:?}"
1342 );
1343 }
1344
1345 let messages = get_by_conversation_id(tx.as_mut(), conversation)
1346 .await
1347 .unwrap();
1348 assert_eq!(
1349 message_summary(&messages),
1350 vec![
1351 "call call_client",
1352 "call call_function",
1353 "output call_client"
1354 ]
1355 );
1356 }
1357
1358 #[tokio::test]
1361 async fn a_waiting_client_tool_call_is_discoverable_from_the_conversation_info() {
1362 insert_data!(:tx);
1363 let (configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1364 insert(
1365 tx.as_mut(),
1366 tool_call_message(conversation, "call_1", ToolKind::ClientTool),
1367 )
1368 .await
1369 .unwrap();
1370 let anonymous_token = chatbot_conversations::get_by_id(tx.as_mut(), conversation)
1371 .await
1372 .unwrap()
1373 .anonymous_token;
1374
1375 let info = chatbot_conversations::get_current_conversation_info(
1376 tx.as_mut(),
1377 None,
1378 anonymous_token,
1379 configuration,
1380 )
1381 .await
1382 .unwrap();
1383
1384 let messages = info
1385 .current_conversation_messages
1386 .expect("the conversation has messages");
1387 assert_eq!(waiting_client_tool_call_ids(&messages), vec!["call_1"]);
1388 let Some(Message::ToolCall(call)) = messages.first().map(|message| &message.message) else {
1389 panic!("the waiting call is in the conversation");
1390 };
1391 assert_eq!(call.tool_kind, ToolKind::ClientTool);
1392 }
1393
1394 #[tokio::test]
1398 async fn no_suggestions_are_offered_while_a_turn_is_suspended() {
1399 insert_data!(:tx);
1400 let (configuration, conversation) =
1401 insert_chatbot_conversation_suggesting_messages(tx.as_mut(), true).await;
1402 let anonymous_token = chatbot_conversations::get_by_id(tx.as_mut(), conversation)
1403 .await
1404 .unwrap()
1405 .anonymous_token;
1406 insert(tx.as_mut(), user_message(conversation, "which loop"))
1407 .await
1408 .unwrap();
1409 insert(
1410 tx.as_mut(),
1411 tool_call_message(conversation, "call_1", ToolKind::ClientTool),
1412 )
1413 .await
1414 .unwrap();
1415
1416 let while_suspended = chatbot_conversations::get_current_conversation_info(
1417 tx.as_mut(),
1418 None,
1419 anonymous_token.clone(),
1420 configuration,
1421 )
1422 .await
1423 .unwrap();
1424
1425 assert!(while_suspended.suggested_messages.is_none());
1426
1427 answer_client_tool_call(
1428 tx.as_mut(),
1429 conversation,
1430 "call_1",
1431 "for loops".to_string(),
1432 None,
1433 )
1434 .await
1435 .unwrap();
1436 let after_the_answer = chatbot_conversations::get_current_conversation_info(
1437 tx.as_mut(),
1438 None,
1439 anonymous_token,
1440 configuration,
1441 )
1442 .await
1443 .unwrap();
1444
1445 assert_eq!(
1446 after_the_answer
1447 .suggested_messages
1448 .map(|suggestions| suggestions.len()),
1449 Some(0)
1450 );
1451 }
1452
1453 #[tokio::test]
1456 async fn turn_is_suspended_only_while_a_client_tool_call_waits() {
1457 insert_data!(:tx);
1458 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1459 insert(tx.as_mut(), user_message(conversation, "which loop"))
1460 .await
1461 .unwrap();
1462 insert(
1463 tx.as_mut(),
1464 tool_call_message(conversation, "call_function", ToolKind::Function),
1465 )
1466 .await
1467 .unwrap();
1468
1469 let with_a_function_call = get_by_conversation_id(tx.as_mut(), conversation)
1470 .await
1471 .unwrap();
1472 assert!(!turn_is_suspended(&with_a_function_call));
1473
1474 insert(
1475 tx.as_mut(),
1476 tool_call_message(conversation, "call_client", ToolKind::ClientTool),
1477 )
1478 .await
1479 .unwrap();
1480 let with_a_waiting_question = get_by_conversation_id(tx.as_mut(), conversation)
1481 .await
1482 .unwrap();
1483 assert!(turn_is_suspended(&with_a_waiting_question));
1484
1485 answer_client_tool_call(
1486 tx.as_mut(),
1487 conversation,
1488 "call_client",
1489 "for loops".to_string(),
1490 None,
1491 )
1492 .await
1493 .unwrap();
1494 let answered = get_by_conversation_id(tx.as_mut(), conversation)
1495 .await
1496 .unwrap();
1497 assert!(!turn_is_suspended(&answered));
1498 }
1499
1500 #[tokio::test]
1503 async fn a_tool_output_of_another_conversation_does_not_answer_a_tool_call() {
1504 insert_data!(:tx);
1505 let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
1506 let (_other_configuration, other_conversation) =
1507 insert_chatbot_conversation(tx.as_mut()).await;
1508 insert(
1509 tx.as_mut(),
1510 tool_call_message(conversation, "call_1", ToolKind::Function),
1511 )
1512 .await
1513 .unwrap();
1514 insert(
1515 tx.as_mut(),
1516 tool_call_message(other_conversation, "call_1", ToolKind::Function),
1517 )
1518 .await
1519 .unwrap();
1520
1521 answer_hanging_tool_call_messages_for_conversation(
1522 tx.as_mut(),
1523 other_conversation,
1524 UnansweredToolCallScope::OwnTurn(&own_turn_response_ids()),
1525 "aborted",
1526 )
1527 .await
1528 .unwrap();
1529
1530 let hanging =
1531 chatbot_conversation_message_tool_calls::get_unanswered_tool_calls_for_conversation(
1532 tx.as_mut(),
1533 conversation,
1534 )
1535 .await
1536 .unwrap();
1537 assert_eq!(hanging.len(), 1);
1538 assert_eq!(hanging[0].tool_call_id, "call_1");
1539 }
1540}