Skip to main content

headless_lms_models/
chatbot_conversation_messages.rs

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    /// A complete text message ready to be inserted by [insert]; `id` and `order_number` are the
60    /// database's to assign.
61    ///
62    /// `used_tokens` is the caller's estimate of what the text costs the LLM's context, and is
63    /// explicit because nothing recomputes it later. `response_id` has to be set for every role
64    /// but the user's: the not_null_for_llm_generated_messages constraint demands one.
65    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
99/// Locks the conversation row so that only one transaction at a time allocates an
100/// `order_number` for it; without this two concurrent inserts pick the same number and one
101/// of them violates the (conversation_id, order_number, deleted_at) unique index.
102///
103/// Errors if the conversation does not exist or has been deleted. The caller's transaction
104/// holds the lock until it ends, so it must be a transaction that only writes messages and
105/// never waits on an LLM request.
106async fn lock_conversation_for_order_number_allocation(
107    conn: &mut PgConnection,
108    conversation_id: Uuid,
109) -> ModelResult<()> {
110    // FOR NO KEY UPDATE excludes other allocators without blocking foreign key checks from
111    // the conversation's other child rows.
112    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
131/// Appends a message to a conversation, allocating the next `order_number` for it.
132///
133/// Inserts both the message row and the row of the inner message type carried by
134/// `input.message`; `input.id` and `input.order_number` are ignored and assigned by the
135/// database. Errors if the conversation does not exist or has been deleted.
136pub 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
147/// [insert] for a caller whose own transaction already holds the conversation lock, which is what
148/// makes the `order_number` allocation safe. Taking it again would only cost round trips.
149async 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
200// todo
201pub 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    // Doubles as the order_number allocation lock, see
217    // lock_conversation_for_order_number_allocation.
218    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/// Whether a reasoning message is read with its `encrypted_content`.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum ReasoningPayload {
296    /// For replaying the conversation to the LLM, which is the only reader of the payload.
297    Include,
298    /// For display: the payload is never serialized to the browser, so reading it only pays
299    /// Postgres to hand back an opaque blob.
300    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
310/// [get_by_conversation_id] for callers that only display the conversation.
311pub 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    // delete the child
369    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
376/// The text of the conversation's newest developer message, which is where its page context is
377/// recorded.
378///
379/// Exists so the page context check does not have to materialize the whole conversation while the
380/// caller holds the conversation lock: [get_by_conversation_id] costs several queries per message,
381/// and every one of them is lock-held latency on the learner's send.
382pub 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
405/// Which unanswered tool calls a repair may abort.
406pub enum UnansweredToolCallScope<'a> {
407    /// The calls of the turn the caller is itself running, named by the responses that turn's
408    /// rounds were given. A call of another turn is one that turn may be about to answer, and a
409    /// `tool_call_id` that ends up with two outputs is replayed as two and rejected by the
410    /// provider for the rest of the conversation.
411    OwnTurn(&'a [String]),
412    /// The calls of any turn that have gone unanswered since the cutoff, for a caller repairing
413    /// what turns it knows nothing about left behind.
414    AnyTurnOlderThan(DateTime<Utc>),
415}
416
417/// Sometimes during chatbot conversation streaming, the stream ends unexpectedly while a
418/// tool call has been made but not answered. This happens also with provider tools that
419/// we can't control. In this case, the conversation is left in a state which is invalid,
420/// so we need to answer the un-answered tool calls to inform of the failure and continue
421/// the conversation.
422///
423/// A [`ClientTool`](chatbot_conversation_message_tool_calls::ToolKind::ClientTool) call with no
424/// output is not a failure but a suspended turn waiting for the client, and is left alone.
425/// [abort_pending_client_tool_calls] is what ends the wait.
426///
427/// `scope` bounds which calls may be aborted, because nothing serializes the requests of one
428/// conversation and a call whose output has not been written yet may belong to a turn still
429/// streaming in another request.
430///
431/// Which calls to answer is decided again under the conversation lock, in one transaction with the
432/// inserts: two sweeps racing would otherwise both answer the same call, and a `tool_call_id`
433/// carrying two outputs is replayed as two and rejected by the provider for the rest of the
434/// conversation.
435///
436/// The read before that only decides whether there is anything to repair, and must stay unlocked.
437/// A request that already holds this conversation's row locked on another pooled connection would
438/// otherwise block here forever on a sweep that had nothing to write.
439///
440/// `output_text` is what an answered call reports to the LLM. Returns the calls left unanswered,
441/// which a caller that goes on to work on one of them can use instead of reading them again.
442pub 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
476/// Answers with `output_text` every unanswered tool call of the conversation that `needs_answering`
477/// accepts, and returns the ones it left alone.
478///
479/// Must be called with the conversation lock held: which calls are unanswered is read here and the
480/// outputs are written against that read, and a call that ends up with two outputs is replayed as
481/// two and rejected by the provider for the rest of the conversation.
482async 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
507/// Ends the wait of every still unanswered
508/// [`ClientTool`](chatbot_conversation_message_tool_calls::ToolKind::ClientTool) call of the
509/// conversation by answering it with `output_text`, so a suspended turn cannot outlive the message
510/// that replaced it.
511///
512/// Must be called inside the transaction that inserts the new user message: it takes the
513/// conversation lock and the caller's transaction holds it to the end, which is what makes the
514/// abort and the new message one decision against a concurrent [answer_client_tool_call]. Called
515/// on its own the lock is released immediately and a resume can slip in between.
516pub 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
529/// Whether the conversation's newest turn is suspended: waiting for the client to answer a
530/// [`ClientTool`](chatbot_conversation_message_tool_calls::ToolKind::ClientTool) call it made.
531///
532/// Such a turn has not ended, it continues in the request that brings the answer, without the
533/// learner writing anything. See [waiting_client_tool_call_ids] for which calls those are.
534pub fn turn_is_suspended(messages: &[ChatbotConversationMessage]) -> bool {
535    !waiting_client_tool_call_ids(messages).is_empty()
536}
537
538/// The `tool_call_id`s of the conversation's client tool calls that no output answers, in message
539/// order: what a suspended turn is waiting for the client to answer.
540///
541/// `messages` are a conversation's messages in order, as [get_by_conversation_id] returns them; a
542/// call is answered by a tool output carrying its `tool_call_id`, which is also how aborting one is
543/// recorded.
544pub 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/// What answering a client tool call left the suspended turn in.
568#[derive(Debug, Clone, PartialEq)]
569pub struct ClientToolAnswerOutcome {
570    pub answer: ChatbotConversationMessage,
571    /// Whether this was the last answer the turn was waiting for, and so the one answerer of a
572    /// round of parallel calls that may resume it.
573    pub turn_can_resume: bool,
574}
575
576/// Records the client's answer to a tool call of a suspended turn and reports whether the turn
577/// may now resume. `output` is the answer in the form the LLM reads; `client_answer` is the
578/// payload the client sent, kept for callers that need the answer as data, and None when no client
579/// answer was applied.
580///
581/// Writing the answer and deciding who resumes happen in one transaction that holds the
582/// conversation lock, so of two clients answering different calls of the same round at the same
583/// time exactly one is told to resume, instead of both or neither. The lock is released by the
584/// commit, before any resumed request is made.
585///
586/// Errors with [ModelErrorType::RecordNotFound] when the conversation has no such call, and with
587/// [ModelErrorType::InvalidRequest] when the call is not one a client answers or already has an
588/// answer. Those are the client's mistakes and no answer is written.
589pub 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    // The unanswered calls already carry the row; the single-row query is only needed on a miss, to
607    // tell a call the conversation does not have from one that already has an answer.
608    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    // A round's parallel calls share the id of the response that made them, and only those hold
651    // this turn: an unanswered call of some other turn, alive or dead, must not suppress the
652    // resume.
653    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
670/// The output message that answers `tool_call`, inheriting the kind and the response id of the
671/// call. An output we author has no Azure response of its own, and inventing an id would collide
672/// with the citation re-pointing that matches on `response_id`.
673fn 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    // update the parent
717    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
732/// The inner message of the given [ChatbotConversationMessage].
733pub 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
740/// The inner message of each of `message_ids`, keyed by the id of the message carrying it.
741///
742/// One query per message kind instead of per message, so reading a conversation costs the same
743/// four round trips however many messages it has. A message with no inner message is absent from
744/// the map.
745async 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    /// The response id every message of these tests is generated under, so that a message and the
936    /// output answering it agree on one.
937    const RESPONSE_ID: &str = "resp_test";
938
939    /// The responses of a turn whose rounds all got [`RESPONSE_ID`].
940    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    /// The conversation as one line per message, for asserting on both content and order.
966    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    /// A tool call left unanswered by a dead turn makes the LLM reject every later message of the
1009    /// conversation, so the sweep at the head of the next turn has to complete it.
1010    #[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    /// What a repeat of the page context is compared against: the newest developer message, not
1052    /// the newest message, so that staying on a page adds no further context however many turns
1053    /// the conversation has. Scoped to the conversation, since a shared page has one context text
1054    /// across every learner reading it.
1055    #[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    /// Nothing serializes two requests for one conversation, so a sweep running at the head of one
1102    /// request must not abort the call of a turn still streaming in another. A cutoff is what
1103    /// separates the two, since a live turn's call was written moments ago.
1104    #[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        // Past the cutoff the same call is fair game, which is what unsticks a conversation whose
1131        // turn really did die.
1132        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    /// A client tool call with no output is a suspended turn waiting for an answer, not a dead one,
1151    /// and looks exactly like an abandoned call to the sweep unless the sweep reads its kind.
1152    #[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    /// The learner sending a new message instead of answering ends the wait, and the aborted call
1196    /// still has to end up with an output so the history stays valid.
1197    #[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    /// Only one answerer of a round of parallel calls may resume the turn, and it has to be the one
1234    /// that completes the round.
1235    #[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    /// An answer the conversation has no room for must be refused cleanly, and must not leave a
1288    /// second output behind for a call that already has one.
1289    #[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    /// A learner who reloads while a call is waiting has to be able to answer it still, and the
1359    /// conversation info is everything the chatbot ui gets to work that out from.
1360    #[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    /// Suggesting what to ask next is generated from the conversation's last message and costs an
1395    /// LLM call, so a suspended turn must not offer any: its last message is the question the
1396    /// chatbot is waiting for an answer to.
1397    #[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    /// What a suspended turn looks like to everything that reads the conversation instead of
1454    /// running it: a client tool call that no output answers.
1455    #[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    /// `tool_call_id` comes from the provider and can repeat across conversations, so an output
1501    /// with the same id elsewhere must not make a call look answered.
1502    #[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}