use entelix_core::ir::{ContentPart, Message, Role};
fn last_assistant_text(messages: &[Message]) -> Option<String> {
let assistant = messages.iter().rev().find(|m| m.role == Role::Assistant)?;
let mut buf = String::new();
for part in &assistant.content {
if let ContentPart::Text { text, .. } = part {
buf.push_str(text);
}
}
if buf.is_empty() { None } else { Some(buf) }
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ChatState {
pub messages: Vec<Message>,
}
impl ChatState {
pub fn from_user(text: impl Into<String>) -> Self {
Self {
messages: vec![Message::user(text)],
}
}
#[must_use]
pub fn last_assistant_text(&self) -> Option<String> {
last_assistant_text(&self.messages)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ReActState {
pub messages: Vec<Message>,
pub steps: usize,
}
impl ReActState {
pub fn from_user(text: impl Into<String>) -> Self {
Self {
messages: vec![Message::user(text)],
steps: 0,
}
}
#[must_use]
pub fn last_assistant_text(&self) -> Option<String> {
last_assistant_text(&self.messages)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SupervisorState {
pub messages: Vec<Message>,
pub last_speaker: Option<String>,
pub next_speaker: Option<crate::supervisor::SupervisorDecision>,
}
impl SupervisorState {
pub fn from_user(text: impl Into<String>) -> Self {
Self {
messages: vec![Message::user(text)],
last_speaker: None,
next_speaker: None,
}
}
#[must_use]
pub fn last_assistant_text(&self) -> Option<String> {
last_assistant_text(&self.messages)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assistant(parts: Vec<ContentPart>) -> Message {
Message::new(Role::Assistant, parts)
}
fn text(s: &str) -> ContentPart {
ContentPart::text(s)
}
#[test]
fn returns_none_when_no_assistant_message_exists() {
let state = ChatState::from_user("hi");
assert_eq!(state.last_assistant_text(), None);
}
#[test]
fn concatenates_every_text_part_of_the_last_assistant_message() {
let mut state = ChatState::from_user("hi");
state
.messages
.push(assistant(vec![text("first"), text(" "), text("second")]));
assert_eq!(state.last_assistant_text(), Some("first second".to_owned()));
}
#[test]
fn skips_non_text_content_parts() {
let mut state = ReActState::from_user("ask");
let tool_use = ContentPart::ToolUse {
id: "tu1".into(),
name: "calc".into(),
input: serde_json::json!({}),
provider_echoes: Vec::new(),
};
state
.messages
.push(assistant(vec![text("before"), tool_use, text("after")]));
assert_eq!(state.last_assistant_text(), Some("beforeafter".to_owned()));
}
#[test]
fn returns_none_when_last_assistant_message_has_no_text() {
let mut state = SupervisorState::from_user("ask");
let tool_use = ContentPart::ToolUse {
id: "tu1".into(),
name: "calc".into(),
input: serde_json::json!({}),
provider_echoes: Vec::new(),
};
state.messages.push(assistant(vec![tool_use]));
assert_eq!(state.last_assistant_text(), None);
}
#[test]
fn returns_text_from_most_recent_assistant_skipping_earlier_turns() {
let mut state = ChatState::from_user("hi");
state.messages.push(assistant(vec![text("old")]));
state.messages.push(Message::user("follow-up"));
state.messages.push(assistant(vec![text("new")]));
assert_eq!(state.last_assistant_text(), Some("new".to_owned()));
}
}