use crate::streaming::{RawStreamingChoice, StreamPartId};
use super::adapter::AdapterOutput;
#[derive(Default)]
pub struct ChunkParts<R> {
pub reasoning: Option<String>,
pub reasoning_signature: Option<String>,
pub text: Option<String>,
pub tool_events: Vec<RawStreamingChoice<R>>,
}
impl<R> ChunkParts<R> {
fn has_boundary_content(&self) -> bool {
self.text.as_ref().is_some_and(|text| !text.is_empty()) || !self.tool_events.is_empty()
}
}
pub struct MintedReasoningLifecycle {
key: StreamPartId,
open: bool,
}
impl MintedReasoningLifecycle {
pub fn new(key: StreamPartId) -> Self {
Self { key, open: false }
}
pub fn emit_chunk<R>(&mut self, parts: ChunkParts<R>, out: &mut AdapterOutput<R>) {
if let Some(reasoning) = parts
.reasoning
.as_ref()
.filter(|reasoning| !reasoning.is_empty())
{
self.open = true;
out.push(Ok(RawStreamingChoice::ReasoningDelta {
id: self.key.clone(),
provider_id: None,
reasoning: reasoning.clone(),
}));
}
if let Some(signature) = parts.reasoning_signature.clone() {
self.open = false;
out.push(Ok(RawStreamingChoice::ReasoningEnd {
id: self.key.clone(),
reasoning: None,
signature: Some(signature),
wire_sent: false,
}));
}
if parts.has_boundary_content() && self.open {
self.open = false;
out.push(Ok(RawStreamingChoice::ReasoningEnd {
id: self.key.clone(),
reasoning: None,
signature: None,
wire_sent: false,
}));
}
if let Some(text) = parts.text.filter(|text| !text.is_empty()) {
out.push(Ok(RawStreamingChoice::Message(text)));
}
for event in parts.tool_events {
out.push(Ok(event));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::streaming::MintKind;
fn lifecycle() -> MintedReasoningLifecycle {
MintedReasoningLifecycle::new(StreamPartId::minted(MintKind::Reasoning, 0))
}
fn emitted(batches: Vec<ChunkParts<()>>) -> Vec<&'static str> {
let mut lifecycle = lifecycle();
let mut out = AdapterOutput::<()>::new();
for parts in batches {
lifecycle.emit_chunk(parts, &mut out);
}
out.iter()
.map(|item| match item {
Ok(RawStreamingChoice::ReasoningDelta { .. }) => "reasoning-delta",
Ok(RawStreamingChoice::ReasoningEnd {
signature: Some(_), ..
}) => "signed-end",
Ok(RawStreamingChoice::ReasoningEnd { .. }) => "bare-end",
Ok(RawStreamingChoice::Message(_)) => "text",
Ok(RawStreamingChoice::ToolCall(_) | RawStreamingChoice::ToolCallDelta { .. }) => {
"tool"
}
_ => "other",
})
.collect()
}
fn tool_event() -> RawStreamingChoice<()> {
RawStreamingChoice::ToolCall(crate::streaming::RawStreamingToolCall::new(
StreamPartId::minted(MintKind::Tool, 0),
"probe".to_owned(),
serde_json::json!({}),
))
}
#[test]
fn a_full_chunk_emits_canonical_order_with_the_boundary_end() {
let order = emitted(vec![ChunkParts {
reasoning: Some("thinking".to_owned()),
reasoning_signature: None,
text: Some("visible".to_owned()),
tool_events: vec![tool_event()],
}]);
assert_eq!(order, vec!["reasoning-delta", "bare-end", "text", "tool"]);
}
#[test]
fn interleaving_content_closes_the_open_block_once() {
let order = emitted(vec![
ChunkParts {
reasoning: Some("thinking".to_owned()),
..ChunkParts::default()
},
ChunkParts {
text: Some("visible".to_owned()),
..ChunkParts::default()
},
ChunkParts {
text: Some("more".to_owned()),
..ChunkParts::default()
},
]);
assert_eq!(order, vec!["reasoning-delta", "bare-end", "text", "text"]);
}
#[test]
fn a_signature_closes_the_block_before_text() {
let order = emitted(vec![ChunkParts {
reasoning: Some("thinking".to_owned()),
reasoning_signature: Some("sig".to_owned()),
text: Some("visible".to_owned()),
tool_events: Vec::new(),
}]);
assert_eq!(order, vec!["reasoning-delta", "signed-end", "text"]);
}
#[test]
fn a_signature_only_chunk_emits_its_close() {
let order = emitted(vec![ChunkParts {
reasoning_signature: Some("sig".to_owned()),
..ChunkParts::default()
}]);
assert_eq!(order, vec!["signed-end"]);
}
#[test]
fn an_empty_chunk_emits_nothing() {
let order = emitted(vec![ChunkParts {
reasoning: Some(String::new()),
reasoning_signature: None,
text: Some(String::new()),
tool_events: Vec::new(),
}]);
assert!(order.is_empty());
}
#[test]
fn reasoning_reopens_after_a_boundary() {
let order = emitted(vec![
ChunkParts {
reasoning: Some("before".to_owned()),
..ChunkParts::default()
},
ChunkParts {
tool_events: vec![tool_event()],
..ChunkParts::default()
},
ChunkParts {
reasoning: Some("after".to_owned()),
..ChunkParts::default()
},
ChunkParts {
text: Some("done".to_owned()),
..ChunkParts::default()
},
]);
assert_eq!(
order,
vec![
"reasoning-delta",
"bare-end",
"tool",
"reasoning-delta",
"bare-end",
"text"
]
);
}
}