use crate::chat::message::{ContentBlock, Message, Role, StopReason};
use crate::chat::response::ChatResponse;
use crate::cost::usage::Usage;
use crate::error::Result;
use crate::model::ModelId;
use futures_core::Stream;
use serde_json::{json, Value};
use std::pin::Pin;
pub type EventStream<'a> = Pin<Box<dyn Stream<Item = Result<Event>> + Send + 'a>>;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Event {
Started {
model: ModelId,
},
TextDelta(String),
ThinkingDelta(String),
ThinkingSignature(String),
ToolUseStarted {
id: String,
name: String,
},
ToolArgumentsDelta(String),
Opaque {
kind: String,
raw: Value,
},
Stopped {
reason: StopReason,
details: Option<String>,
},
Metered(Usage),
}
#[derive(Debug, Clone)]
pub struct Transcript {
blocks: Vec<ContentBlock>,
pending_tool: Option<(String, String, String)>,
stop: Option<(StopReason, Option<String>)>,
usage: Option<Usage>,
served_by: Option<ModelId>,
asked_for: ModelId,
}
impl Transcript {
pub fn new(asked_for: impl Into<ModelId>) -> Self {
Self {
blocks: Vec::new(),
pending_tool: None,
stop: None,
usage: None,
served_by: None,
asked_for: asked_for.into(),
}
}
pub fn push(&mut self, event: Event) {
match event {
Event::Started { model } => self.served_by = Some(model),
Event::TextDelta(text) => {
self.close_tool();
match self.blocks.last_mut() {
Some(ContentBlock::Text(existing)) => existing.push_str(&text),
_ => self.blocks.push(ContentBlock::Text(text)),
}
}
Event::ThinkingDelta(text) => {
self.close_tool();
match self.blocks.last_mut() {
Some(ContentBlock::Thinking {
text: existing,
signature: None,
}) => existing.push_str(&text),
_ => self.blocks.push(ContentBlock::Thinking {
text,
signature: None,
}),
}
}
Event::ThinkingSignature(sig) => {
self.close_tool();
if let Some(ContentBlock::Thinking { signature, .. }) =
self.blocks.iter_mut().rev().find(|b| {
matches!(
b,
ContentBlock::Thinking {
signature: None,
..
}
)
})
{
*signature = Some(sig);
} else {
self.blocks.push(ContentBlock::Opaque {
kind: "signature_without_block".into(),
raw: json!({ "signature": sig }),
});
}
}
Event::ToolUseStarted { id, name } => {
self.close_tool();
self.pending_tool = Some((id, name, String::new()));
}
Event::ToolArgumentsDelta(fragment) => {
if let Some((_, _, args)) = self.pending_tool.as_mut() {
args.push_str(&fragment);
}
}
Event::Opaque { kind, raw } => {
self.close_tool();
self.blocks.push(ContentBlock::Opaque { kind, raw });
}
Event::Stopped { reason, details } => {
self.close_tool();
self.stop = Some((reason, details));
}
Event::Metered(usage) => {
self.usage = Some(match self.usage {
Some(existing) => existing.merge(usage),
None => usage,
});
}
}
}
pub async fn drain(&mut self, mut stream: EventStream<'_>) -> Result<()> {
while let Some(event) = std::future::poll_fn(|cx| Pin::new(&mut stream).poll_next(cx)).await
{
self.push(event?);
}
Ok(())
}
pub fn text(&self) -> String {
self.blocks
.iter()
.filter_map(ContentBlock::answer_text)
.collect()
}
pub fn is_finished(&self) -> bool {
self.stop.is_some()
}
pub fn finish(mut self) -> ChatResponse {
self.close_tool();
let (reason, details) = self.stop.unwrap_or((
StopReason::Interrupted,
Some("the stream ended early".into()),
));
let mut response = ChatResponse::new(
Message {
role: Role::Assistant,
content: self.blocks,
},
reason,
self.usage.unwrap_or_else(Usage::absent),
self.served_by.unwrap_or(self.asked_for),
);
if let Some(details) = details {
response = response.with_stop_details(details);
}
response
}
fn close_tool(&mut self) {
let Some((id, name, arguments)) = self.pending_tool.take() else {
return;
};
self.blocks.push(ContentBlock::ToolUse {
id,
name,
input: serde_json::from_str(&arguments).unwrap_or_else(|_| json!({ "raw": arguments })),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_deltas_join_into_one_block_rather_than_many() {
let mut t = Transcript::new("m");
t.push(Event::TextDelta("Hel".into()));
t.push(Event::TextDelta("lo".into()));
t.push(Event::Stopped {
reason: StopReason::EndTurn,
details: None,
});
let reply = t.finish();
assert_eq!(reply.text(), "Hello");
assert_eq!(reply.message.content.len(), 1);
}
#[test]
fn a_signature_lands_on_the_thinking_block_it_belongs_to() {
let mut t = Transcript::new("m");
t.push(Event::ThinkingDelta("becau".into()));
t.push(Event::ThinkingDelta("se".into()));
t.push(Event::ThinkingSignature("sig-abc".into()));
t.push(Event::TextDelta("Four.".into()));
let reply = t.finish();
assert_eq!(
reply.message.content[0],
ContentBlock::Thinking {
text: "because".into(),
signature: Some("sig-abc".into()),
}
);
}
#[test]
fn usage_that_never_arrived_is_absent_rather_than_zero() {
let mut t = Transcript::new("m");
t.push(Event::TextDelta("ok".into()));
assert_eq!(
t.finish().usage.coverage(),
crate::cost::usage::UsageCoverage::Absent
);
}
#[test]
fn two_usage_events_are_merged_rather_than_the_last_one_winning() {
let mut t = Transcript::new("m");
t.push(Event::Metered(Usage::absent().with_input(10)));
t.push(Event::Metered(Usage::absent().with_output(7)));
let usage = t.finish().usage;
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(7));
}
#[test]
fn a_stream_that_stopped_early_is_not_a_finished_answer() {
let mut t = Transcript::new("m");
t.push(Event::TextDelta("half an answ".into()));
let reply = t.finish();
assert_eq!(reply.stop_reason, StopReason::Interrupted);
assert!(
!reply.is_complete(),
"a cut off reply must not read as done"
);
assert_eq!(reply.text(), "half an answ", "what arrived is still ours");
}
#[test]
fn tool_arguments_assemble_and_survive_not_parsing() {
let mut t = Transcript::new("m");
t.push(Event::ToolUseStarted {
id: "call_1".into(),
name: "search".into(),
});
t.push(Event::ToolArgumentsDelta("{\"q\":".into()));
t.push(Event::ToolArgumentsDelta("\"rust\"}".into()));
t.push(Event::Stopped {
reason: StopReason::ToolUse,
details: None,
});
match &t.finish().message.content[0] {
ContentBlock::ToolUse { input, .. } => assert_eq!(input["q"], "rust"),
other => panic!("expected a tool call, got {other:?}"),
}
let mut broken = Transcript::new("m");
broken.push(Event::ToolUseStarted {
id: "call_2".into(),
name: "search".into(),
});
broken.push(Event::ToolArgumentsDelta("{not json".into()));
match &broken.finish().message.content[0] {
ContentBlock::ToolUse { input, .. } => assert_eq!(input["raw"], "{not json"),
other => panic!("expected a tool call, got {other:?}"),
}
}
#[test]
fn the_model_that_answered_wins_over_the_one_that_was_asked_for() {
let mut t = Transcript::new("asked-for");
t.push(Event::Started {
model: "served-by".into(),
});
assert_eq!(t.finish().model.as_str(), "served-by");
}
#[test]
fn a_stream_that_named_no_model_falls_back_to_the_one_requested() {
assert_eq!(
Transcript::new("asked-for").finish().model.as_str(),
"asked-for"
);
}
}