Skip to main content

headless_lms_chatbot/azure_chatbot/turn/
text_response.rs

1//! Streaming a text answer to the learner, and handing it to the turn once Azure has finished it.
2
3use std::sync::Arc;
4
5use futures::StreamExt;
6use futures::stream::BoxStream;
7use tokio::sync::Mutex;
8use tracing::trace;
9
10use crate::azure_chatbot::azure::protocol::{
11    OutputItem, ReceivedOutputItem, ResponseOutput, check_response_complete, check_response_output,
12};
13use crate::azure_chatbot::azure::sse::{AzureStreamEvent, ParsedResponseLine};
14use crate::azure_chatbot::azure::transport::ResponseLinesStream;
15use crate::azure_chatbot::events::{StreamItem, TurnEvent};
16use crate::chatbot_error::ChatbotResult;
17use crate::llm_utils::estimate_tokens;
18use crate::prelude::*;
19
20/// Parses the rest of a round already classified as a text answer, to the end of the Azure stream.
21///
22/// Yields [`TurnEvent::Delta`] per streamed token, [`TurnEvent::Item`] for the non-message items
23/// that accompany it, and [`TurnEvent::Done`] with the finished answer, which the caller stores.
24/// Every delta is also appended to `full_response_text`, so that the cancellation guard knows what
25/// it may still save for a turn that never reaches `Done`. Errors if Azure reports a failure, if a
26/// tool call arrives mid-answer, or if the stream ends unfinished.
27pub(super) async fn parse_text_response<'a>(
28    mut lines: ResponseLinesStream<'a>,
29    full_response_text: Arc<Mutex<String>>,
30    response_id: String,
31) -> BoxStream<'a, ChatbotResult<TurnEvent>> {
32    trace!("Parsing stream to user...");
33
34    let mut response_received = false;
35    let mut response_incomplete = false;
36    let mut preceding_event: Option<AzureStreamEvent> = None;
37
38    Box::pin(async_stream::try_stream! {
39        while let Some(val) = lines.next().await {
40            let line = val?;
41            let response_output: ResponseOutput = match ParsedResponseLine::parse(&line)? {
42                Some(ParsedResponseLine::Event(event)) => {
43                    trace!("Event: {event:?}");
44                    match &event {
45                        AzureStreamEvent::ResponseCompleted => {response_received = true;},
46                        AzureStreamEvent::Incomplete => {response_received = true; response_incomplete = true;},
47                        AzureStreamEvent::FunctionCallArgumentsDelta | AzureStreamEvent::CustomToolCallInputDelta => {
48                            error!("ERROR, function call received but can't be processed while streaming to user.");
49                            return Err(chatbot_err!(UnexpectedProtocolShape, "Unexpected function call while streaming to user"))?
50                        },
51                        AzureStreamEvent::ErrorReported => {
52                            // error is logged in the next iteration
53                        }
54                        _ => {}
55                    };
56                    preceding_event = Some(event);
57                    continue;
58                },
59                Some(ParsedResponseLine::Data(data)) => *data,
60                None => {continue;},
61            };
62
63            let event = AzureStreamEvent::of_data_line(preceding_event.take(), response_output.response_type.as_deref());
64
65            check_response_output(&response_output, Some(&response_id), "streaming_answer")?;
66
67            // Locked only where the transcript is actually touched: `?` inside `try_stream!`
68            // parks the generator instead of returning, so a guard alive at one would never be
69            // released and the turn's cleanup would wait on it forever.
70            if response_received {
71                // An answer Azure cut short must not be stored or shown as a finished one, so it
72                // ends the turn as an error even though its text is kept.
73                check_response_complete(&response_output, response_incomplete)?;
74                let full_response_as_string = full_response_text.lock().await.clone();
75                // todo: use the tokens given in the response
76                let estimated_cost = estimate_tokens(&full_response_as_string);
77                trace!(
78                    "End of chatbot response stream. Estimated cost: {}. Response: {}",
79                    estimated_cost, full_response_as_string
80                );
81                // Only the answer's own tokens. The conversation the request carried is already
82                // counted on the messages it is built from, and a turn suspended on a client tool
83                // call carries that same prefix again in every request that resumes it.
84                yield TurnEvent::Done { text: full_response_as_string, used_tokens: estimated_cost };
85                return;
86            }
87
88            // A reasoning summary streams its own deltas on the same `delta` field; only the
89            // answer's belong in what the learner reads and what gets stored as the answer.
90            if let Some(delta) = response_output.delta
91                && matches!(event, Some(AzureStreamEvent::OutputTextDelta | AzureStreamEvent::RefusalDelta))
92            {
93                full_response_text.lock().await.push_str(&delta);
94                yield TurnEvent::Delta(delta);
95            }
96
97            if let Some(item) = response_output.item.and_then(ReceivedOutputItem::known) {
98                match item {
99                    OutputItem::Message { .. } => continue,
100                    OutputItem::FunctionCall { .. } => Err(chatbot_err!(UnexpectedProtocolShape, "Error: unexpected function call after / during a text response.".to_string()))?,
101                    item => {
102                        let finished = matches!(event, Some(AzureStreamEvent::OutputItemDone));
103                        yield TurnEvent::Item(StreamItem::Received { item, finished });
104                        continue;
105                    },
106                };
107            }
108        }
109        // Reached only when Azure stopped sending before it completed the response.
110        Err(chatbot_err!(StreamEndedEarly, "Stream ended unexpectedly"))?;
111    })
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::azure_chatbot::test_helpers::azure_response_stream;
118
119    /// `Done` reports `used_tokens` as the estimate of the deltas this call streamed, not of
120    /// anything the caller may have seeded `full_response_text` with. Whether that accumulator
121    /// really starts empty for every round is `stream_turn`'s guarantee, not this parser's — this
122    /// only pins what `parse_text_response` itself computes from it.
123    #[tokio::test]
124    async fn done_reports_the_token_estimate_of_the_streamed_deltas() {
125        let answer = "A for loop.";
126
127        let mut events = parse_text_response(
128            azure_response_stream(&[
129                "event: response.output_text.delta",
130                &format!(r#"data: {{"type":"response.output_text.delta","delta":"{answer}"}}"#),
131                "event: response.completed",
132                r#"data: {"type":"response.completed","response":{"id":"resp_answer"}}"#,
133            ]),
134            Arc::new(Mutex::new(String::new())),
135            "resp_answer".to_string(),
136        )
137        .await;
138
139        let mut finished = None;
140        while let Some(event) = events.next().await {
141            if let TurnEvent::Done { text, used_tokens } =
142                event.expect("the response streams to the end")
143            {
144                finished = Some((text, used_tokens));
145            }
146        }
147
148        assert_eq!(
149            finished.expect("the answer finishes"),
150            (answer.to_string(), estimate_tokens(answer))
151        );
152    }
153
154    /// A proxy that closes the body early must not read as an answer with nothing left to say.
155    #[tokio::test]
156    async fn a_stream_that_ends_before_response_completed_errors() {
157        let mut events = parse_text_response(
158            azure_response_stream(&[
159                "event: response.output_text.delta",
160                r#"data: {"type":"response.output_text.delta","delta":"Cut off"}"#,
161            ]),
162            Arc::new(Mutex::new(String::new())),
163            "resp_answer".to_string(),
164        )
165        .await;
166
167        let error = loop {
168            match events.next().await.expect("the stream ends in an error") {
169                Ok(TurnEvent::Delta(_)) => continue,
170                Ok(other) => panic!("expected only a delta before the error, got {other:?}"),
171                Err(error) => break error,
172            }
173        };
174        assert_eq!(*error.error_type(), ChatbotErrorType::StreamEndedEarly);
175    }
176
177    /// `max_output_tokens` truncation ends the round on `response.incomplete` instead of
178    /// `response.completed`, and the answer must not be reported as finished.
179    #[tokio::test]
180    async fn a_response_incomplete_event_errors_instead_of_finishing() {
181        let mut events = parse_text_response(
182            azure_response_stream(&[
183                "event: response.output_text.delta",
184                r#"data: {"type":"response.output_text.delta","delta":"Cut off"}"#,
185                "event: response.incomplete",
186                r#"data: {"type":"response.incomplete","response":{"id":"resp_answer","incomplete_details":{"reason":"max_output_tokens"}}}"#,
187            ]),
188            Arc::new(Mutex::new(String::new())),
189            "resp_answer".to_string(),
190        )
191        .await;
192
193        let error = loop {
194            match events.next().await.expect("the stream ends in an error") {
195                Ok(TurnEvent::Delta(_)) => continue,
196                Ok(other) => panic!("expected only a delta before the error, got {other:?}"),
197                Err(error) => break error,
198            }
199        };
200        assert_eq!(*error.error_type(), ChatbotErrorType::ResponseIncomplete);
201    }
202}