Skip to main content

headless_lms_chatbot/azure_chatbot/
events.rs

1//! What the turn reports: the NDJSON events the client reads, and the events the parsers pass
2//! between themselves on the way there.
3
4use std::pin::Pin;
5
6use bytes::Bytes;
7use futures::Stream;
8use serde::{Deserialize, Serialize};
9use utoipa::ToSchema;
10
11use super::azure::protocol::OutputItem;
12use crate::azure_chatbot::azure::tools::AZURE_AI_SEARCH_TOOL_NAME;
13use crate::chatbot_error::ChatbotResult;
14use crate::llm_utils::APIInputMessage;
15use crate::prelude::*;
16
17/// The wire event a [`StreamItem`] becomes, or `None` for an item that has no shape on the wire
18/// (a `Message` item, which is reported through the text-delta path instead).
19pub(super) fn stream_event_for(value: StreamItem) -> Option<ChatbotChatStreamEvent> {
20    let (item, finished) = match value {
21        StreamItem::ServerToolOutput { call_id } => return Some(finished_tool_call(call_id)),
22        StreamItem::Received { item, finished } => (item, finished),
23    };
24    Some(match item {
25        OutputItem::Reasoning { id, .. } => ChatbotChatStreamEvent::Reasoning {
26            finished,
27            reasoning_id: id,
28        },
29        OutputItem::AzureAiSearchCall {
30            arguments, call_id, ..
31        } => ChatbotChatStreamEvent::ToolCall {
32            tool_name: Some(AZURE_AI_SEARCH_TOOL_NAME.to_string()),
33            arguments: Some(arguments),
34            tool_call_id: call_id,
35            finished,
36        },
37        // A call the model made counts as finished only once its output arrives, so Azure's
38        // finished copy of the call itself is still reported unfinished.
39        OutputItem::FunctionCall {
40            tool_name,
41            arguments,
42            call_id,
43            ..
44        } => ChatbotChatStreamEvent::ToolCall {
45            tool_name: Some(tool_name),
46            arguments: Some(arguments),
47            tool_call_id: call_id,
48            finished: false,
49        },
50        OutputItem::AzureAiSearchCallOutput { call_id, .. } => ChatbotChatStreamEvent::ToolCall {
51            tool_name: Some(AZURE_AI_SEARCH_TOOL_NAME.to_string()),
52            arguments: None,
53            tool_call_id: call_id,
54            finished: true,
55        },
56        OutputItem::FunctionCallOutput { call_id, .. } => finished_tool_call(call_id),
57        OutputItem::Message { .. } => return None,
58    })
59}
60
61/// Tells the client a call it has already seen is done. Carries no tool name or arguments: the
62/// frontend reads neither, and it already has both from the event that announced the call.
63fn finished_tool_call(call_id: String) -> ChatbotChatStreamEvent {
64    ChatbotChatStreamEvent::ToolCall {
65        tool_name: None,
66        arguments: None,
67        tool_call_id: call_id,
68        finished: true,
69    }
70}
71
72#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
73#[serde(tag = "type", content = "data")]
74pub enum ChatbotChatStreamEvent {
75    Delta {
76        text: String,
77        message_id: Uuid,
78    },
79    Reasoning {
80        finished: bool,
81        reasoning_id: String,
82    },
83    ToolCall {
84        tool_name: Option<String>,
85        arguments: Option<String>,
86        tool_call_id: String,
87        finished: bool,
88    },
89    Done,
90    /// The turn stopped to wait for the client to answer a tool call, so it ends with neither an
91    /// answer nor an error. Terminal like `Done`: the client stops reading, answers the call
92    /// through the tool-response endpoint, and reads the stream that returns.
93    ///
94    /// Carries nothing, because the call it waits on was already streamed as an unfinished
95    /// `ToolCall` event, and survives a reload only through the conversation's messages anyway.
96    Suspended,
97    /// A confirmed action tool call executed, carrying data for the confirming admin's browser
98    /// only (e.g. a reset link). Never persisted: it is not in `payload` again after a reload, and
99    /// the model never sees it either.
100    ActionExecuted {
101        tool_call_id: String,
102        payload: serde_json::Value,
103    },
104    Error(StreamEventError),
105}
106
107#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
108pub struct StreamEventError {
109    message: String,
110    details: Option<String>,
111}
112
113/// What the two body parsers — the tool-call round and the text answer — yield once a response is
114/// classified. One type shared between them rather than split further: both stream `Item`, while
115/// `Refusal`/`ItemAnnounced`/`Messages`/`Suspended` come only from the tool-call round and `Delta`/
116/// `Done` only from the text answer.
117#[derive(Debug)]
118pub(super) enum TurnEvent {
119    Delta(String),
120    /// Text the model refused with, and the message it was stored as. A refusal is stored before
121    /// it is streamed, so unlike a `Delta` it names the message it belongs to itself.
122    Refusal {
123        text: String,
124        message_id: Uuid,
125    },
126    Item(StreamItem),
127    /// An item the tool-call round stores itself, at its position among the round's other items,
128    /// instead of leaving that to whichever loop consumes this event. The consumer converts and
129    /// forwards it to the client exactly like `Item`, but must not persist it.
130    ItemAnnounced(OutputItem),
131    Messages(Vec<APIInputMessage>),
132    /// The answer Azure finished, for the turn to store on the message it streamed from.
133    Done {
134        text: String,
135        /// What the answer alone adds to the conversation's token count.
136        used_tokens: i32,
137    },
138    /// The round ended in a tool call only the client can answer, so the turn ends here and is
139    /// continued by the request that brings the answer.
140    Suspended,
141}
142
143/// One item the turn reports to the client.
144#[derive(Debug, Clone)]
145pub(super) enum StreamItem {
146    /// An item Azure sent, and whether this is its finished copy rather than the one that only
147    /// announced it.
148    Received { item: OutputItem, finished: bool },
149    /// The output of a tool call this server answered itself. Azure sends no such item, and the
150    /// round that ran the call has already stored the output, so all the client is owed is that
151    /// the call it saw is finished.
152    ServerToolOutput { call_id: String },
153}
154
155/// Frames one event as the NDJSON line the client reads: the wire format's single boundary.
156pub(super) fn ndjson_line(event: &ChatbotChatStreamEvent) -> ChatbotResult<Bytes> {
157    let mut line = serde_json::to_string(event)?;
158    line.push('\n');
159    Ok(Bytes::from(line))
160}
161
162/// The framed error event for a message this code raised itself, with no underlying [`ChatbotError`].
163pub(super) fn error_event_from_text(message: &str) -> ChatbotResult<Bytes> {
164    ndjson_line(&ChatbotChatStreamEvent::Error(StreamEventError {
165        message: message.to_string(),
166        details: None,
167    }))
168}
169
170/// The framed error event for a failed [`ChatbotError`], detailing the Azure error when the
171/// failure came with one.
172pub(super) fn error_event_from_error(error: &ChatbotError) -> ChatbotResult<Bytes> {
173    let details = match error.azure_source() {
174        Some(source) => format!("{source}"),
175        None => error.message().to_string(),
176    };
177    ndjson_line(&ChatbotChatStreamEvent::Error(StreamEventError {
178        message: error.message().to_string(),
179        details: Some(details),
180    }))
181}
182
183/// A stream that carries one event and ends, for a response with no turn behind it.
184pub(super) fn single_event_stream(
185    event: ChatbotChatStreamEvent,
186) -> ChatbotResult<Pin<Box<dyn Stream<Item = ChatbotResult<Bytes>> + Send>>> {
187    let line = ndjson_line(&event)?;
188    Ok(Box::pin(futures::stream::once(async move { Ok(line) })))
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    /// `Suspended` is terminal for the frontend reader, which tells the variants apart by `type`
196    /// alone, and like `Done` it carries no `data` key at all.
197    #[test]
198    fn the_suspended_event_serialises_without_a_data_key() {
199        assert_eq!(
200            serde_json::to_string(&ChatbotChatStreamEvent::Suspended).unwrap(),
201            r#"{"type":"Suspended"}"#
202        );
203    }
204}