use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct MessageId(u64);
impl MessageId {
pub fn get(self) -> u64 {
self.0
}
}
impl std::fmt::Display for MessageId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ChatRole {
User,
Assistant,
System,
}
impl ChatRole {
pub fn label(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum MessageState {
#[default]
Complete,
Streaming,
Stopped,
Error {
message: String,
},
}
impl MessageState {
pub fn is_streaming(&self) -> bool {
matches!(self, Self::Streaming)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SourceRef {
pub id: String,
pub title: String,
pub location: Option<String>,
pub score: Option<f32>,
}
impl SourceRef {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
location: None,
score: None,
}
}
pub fn location(mut self, location: impl Into<String>) -> Self {
self.location = Some(location.into());
self
}
pub fn score(mut self, score: f32) -> Self {
self.score = Some(score);
self
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MessageMeta {
pub model: Option<String>,
pub chunks: Option<usize>,
pub sources: Vec<SourceRef>,
pub tokens: Option<usize>,
pub elapsed: Option<Duration>,
}
impl MessageMeta {
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if let Some(model) = &self.model {
parts.push(model.clone());
}
if let Some(chunks) = self.chunks {
parts.push(format!("{chunks} chunks"));
}
if !self.sources.is_empty() {
let count = self.sources.len();
parts.push(if count == 1 {
"1 source".to_owned()
} else {
format!("{count} sources")
});
}
if let Some(tokens) = self.tokens {
parts.push(format!("{tokens} tokens"));
}
if let Some(elapsed) = self.elapsed {
parts.push(format!("{:.1} s", elapsed.as_secs_f32()));
}
parts.join(" · ")
}
pub fn is_empty(&self) -> bool {
self.model.is_none()
&& self.chunks.is_none()
&& self.sources.is_empty()
&& self.tokens.is_none()
&& self.elapsed.is_none()
}
pub fn add_source(&mut self, source: SourceRef) -> bool {
if self.sources.iter().any(|existing| existing.id == source.id) {
return false;
}
self.sources.push(source);
true
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ChatMessage {
id: MessageId,
role: ChatRole,
text: String,
state: MessageState,
meta: Option<MessageMeta>,
}
impl ChatMessage {
pub fn id(&self) -> MessageId {
self.id
}
pub fn role(&self) -> ChatRole {
self.role
}
pub fn text(&self) -> &str {
&self.text
}
pub fn state(&self) -> &MessageState {
&self.state
}
pub fn meta(&self) -> Option<&MessageMeta> {
self.meta.as_ref()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct NewMessage {
pub role: ChatRole,
pub text: String,
pub state: MessageState,
pub meta: Option<MessageMeta>,
}
impl NewMessage {
pub fn user(text: impl Into<String>) -> Self {
Self::new(ChatRole::User, text)
}
pub fn assistant(text: impl Into<String>) -> Self {
Self::new(ChatRole::Assistant, text)
}
pub fn system(text: impl Into<String>) -> Self {
Self::new(ChatRole::System, text)
}
pub fn new(role: ChatRole, text: impl Into<String>) -> Self {
Self {
role,
text: text.into(),
state: MessageState::Complete,
meta: None,
}
}
pub fn streaming(mut self) -> Self {
self.state = MessageState::Streaming;
self
}
pub fn state(mut self, state: MessageState) -> Self {
self.state = state;
self
}
pub fn meta(mut self, meta: MessageMeta) -> Self {
self.meta = Some(meta);
self
}
}
#[derive(Debug, Default)]
pub struct ChatState {
next_id: u64,
messages: Vec<ChatMessage>,
}
impl ChatState {
pub fn messages(&self) -> &[ChatMessage] {
&self.messages
}
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
pub fn get(&self, id: MessageId) -> Option<&ChatMessage> {
self.messages.iter().find(|message| message.id == id)
}
pub fn last(&self) -> Option<&ChatMessage> {
self.messages.last()
}
pub fn last_assistant(&self) -> Option<&ChatMessage> {
self.messages
.iter()
.rev()
.find(|message| message.role == ChatRole::Assistant)
}
pub fn streaming_id(&self) -> Option<MessageId> {
self.messages
.iter()
.rev()
.find(|message| message.state.is_streaming())
.map(|message| message.id)
}
pub fn is_streaming(&self) -> bool {
self.streaming_id().is_some()
}
pub fn push(&mut self, message: NewMessage) -> MessageId {
let id = MessageId(self.next_id);
self.next_id += 1;
self.messages.push(ChatMessage {
id,
role: message.role,
text: message.text,
state: message.state,
meta: message.meta,
});
id
}
pub fn insert_before(&mut self, before: MessageId, message: NewMessage) -> Option<MessageId> {
let index = self
.messages
.iter()
.position(|candidate| candidate.id == before)?;
let id = MessageId(self.next_id);
self.next_id += 1;
self.messages.insert(
index,
ChatMessage {
id,
role: message.role,
text: message.text,
state: message.state,
meta: message.meta,
},
);
Some(id)
}
pub fn push_user(&mut self, text: impl Into<String>) -> MessageId {
self.push(NewMessage::user(text))
}
pub fn push_system(&mut self, text: impl Into<String>) -> MessageId {
self.push(NewMessage::system(text))
}
pub fn begin_assistant(&mut self) -> MessageId {
self.push(NewMessage::assistant("").streaming())
}
pub fn append(&mut self, id: MessageId, delta: &str) -> bool {
let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else {
return false;
};
if !message.state.is_streaming() {
return false;
}
message.text.push_str(delta);
true
}
pub fn finish(&mut self, id: MessageId) -> bool {
self.transition(id, &MessageState::Complete)
}
pub fn stop(&mut self, id: MessageId) -> bool {
self.transition(id, &MessageState::Stopped)
}
pub fn fail(&mut self, id: MessageId, error: impl Into<String>) -> bool {
let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else {
return false;
};
message.state = MessageState::Error {
message: error.into(),
};
true
}
pub fn set_meta(&mut self, id: MessageId, meta: MessageMeta) -> bool {
let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else {
return false;
};
message.meta = Some(meta);
true
}
pub fn set_text(&mut self, id: MessageId, text: impl Into<String>) -> bool {
let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else {
return false;
};
message.text = text.into();
true
}
pub fn add_source(&mut self, id: MessageId, source: SourceRef) -> bool {
let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else {
return false;
};
message
.meta
.get_or_insert_with(MessageMeta::default)
.add_source(source)
}
pub fn clear(&mut self) {
self.messages.clear();
}
pub fn remove(&mut self, id: MessageId) -> bool {
let before = self.messages.len();
self.messages.retain(|message| message.id != id);
self.messages.len() != before
}
pub fn replace(&mut self, messages: impl IntoIterator<Item = NewMessage>) -> Vec<MessageId> {
self.messages.clear();
messages
.into_iter()
.map(|message| self.push(message))
.collect()
}
fn transition(&mut self, id: MessageId, next: &MessageState) -> bool {
let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else {
return false;
};
if !message.state.is_streaming() {
return false;
}
message.state = next.clone();
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_streamed_message_is_reduced_in_order() {
let mut state = ChatState::default();
let user = state.push_user("Explain the method");
let assistant = state.begin_assistant();
assert_eq!(state.len(), 2);
assert_eq!(state.messages()[0].id(), user);
assert_eq!(state.messages()[0].role(), ChatRole::User);
assert!(state.append(assistant, "The "));
assert!(state.append(assistant, "method."));
assert!(state.finish(assistant));
let message = state.get(assistant).expect("assistant message");
assert_eq!(message.text(), "The method.");
assert_eq!(message.state(), &MessageState::Complete);
assert!(!state.is_streaming());
}
#[test]
fn stopping_keeps_the_partial_body_and_rejects_late_tokens() {
let mut state = ChatState::default();
let assistant = state.begin_assistant();
assert!(state.append(assistant, "Partial"));
assert!(state.stop(assistant));
assert!(!state.append(assistant, " late"));
assert!(!state.finish(assistant));
let message = state.get(assistant).expect("assistant message");
assert_eq!(message.text(), "Partial");
assert_eq!(message.state(), &MessageState::Stopped);
}
#[test]
fn failing_keeps_the_partial_body_and_reason() {
let mut state = ChatState::default();
let assistant = state.begin_assistant();
assert!(state.append(assistant, "Partial"));
assert!(state.fail(assistant, "connection reset"));
let message = state.get(assistant).expect("assistant message");
assert_eq!(message.text(), "Partial");
assert_eq!(
message.state(),
&MessageState::Error {
message: "connection reset".to_owned()
}
);
}
#[test]
fn metadata_and_sources_are_attached_and_deduplicated() {
let mut state = ChatState::default();
let assistant = state.begin_assistant();
assert!(state.set_meta(
assistant,
MessageMeta {
model: Some("deepseek-chat".to_owned()),
chunks: Some(12),
..Default::default()
}
));
assert!(state.add_source(assistant, SourceRef::new("paper", "Paper")));
assert!(!state.add_source(assistant, SourceRef::new("paper", "Paper again")));
let message = state.get(assistant).expect("assistant message");
let meta = message.meta().expect("meta");
assert_eq!(meta.sources.len(), 1);
assert_eq!(meta.summary(), "deepseek-chat · 12 chunks · 1 source");
}
#[test]
fn removing_a_message_keeps_the_rest_in_order() {
let mut state = ChatState::default();
let first = state.push_user("first");
let second = state.push_user("second");
let third = state.push_user("third");
assert!(state.remove(second));
assert!(!state.remove(second));
assert_eq!(
state
.messages()
.iter()
.map(ChatMessage::id)
.collect::<Vec<_>>(),
vec![first, third]
);
}
#[test]
fn inserting_before_keeps_the_transcript_order() {
let mut state = ChatState::default();
let user = state.push_user("question");
let assistant = state.begin_assistant();
let note = state
.insert_before(assistant, NewMessage::system("Attached: @Kano2025"))
.expect("insert before the assistant");
assert!(note > assistant);
assert_eq!(
state
.messages()
.iter()
.map(ChatMessage::role)
.collect::<Vec<_>>(),
vec![ChatRole::User, ChatRole::System, ChatRole::Assistant]
);
assert_eq!(state.messages()[0].id(), user);
assert_eq!(state.messages()[2].id(), assistant);
assert!(
state
.insert_before(MessageId(999), NewMessage::system("late"))
.is_none()
);
assert_eq!(state.len(), 3);
}
#[test]
fn replacing_a_transcript_assigns_fresh_identities() {
let mut state = ChatState::default();
let old = state.push_user("old");
let ids = state.replace([NewMessage::user("new"), NewMessage::assistant("answer")]);
assert_eq!(state.len(), 2);
assert_eq!(state.messages()[0].text(), "new");
assert!(ids.iter().all(|id| *id > old));
}
#[test]
fn append_and_transitions_ignore_unknown_ids() {
let mut state = ChatState::default();
let id = MessageId(999);
assert!(!state.append(id, "x"));
assert!(!state.finish(id));
assert!(!state.stop(id));
assert!(!state.fail(id, "x"));
assert!(!state.set_meta(id, MessageMeta::default()));
assert!(!state.add_source(id, SourceRef::new("s", "S")));
}
#[test]
fn a_finished_message_does_not_accept_more_tokens() {
let mut state = ChatState::default();
let assistant = state.begin_assistant();
assert!(state.finish(assistant));
assert!(!state.append(assistant, "late"));
assert_eq!(state.get(assistant).expect("assistant").text(), "");
}
}