1use entelix_core::ir::{ContentPart, Message, Role};
8
9fn last_assistant_text(messages: &[Message]) -> Option<String> {
19 let assistant = messages.iter().rev().find(|m| m.role == Role::Assistant)?;
20 let mut buf = String::new();
21 for part in &assistant.content {
22 if let ContentPart::Text { text, .. } = part {
23 buf.push_str(text);
24 }
25 }
26 if buf.is_empty() { None } else { Some(buf) }
27}
28
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct ChatState {
33 pub messages: Vec<Message>,
36}
37
38impl ChatState {
39 pub fn from_user(text: impl Into<String>) -> Self {
41 Self {
42 messages: vec![Message::user(text)],
43 }
44 }
45
46 #[must_use]
50 pub fn last_assistant_text(&self) -> Option<String> {
51 last_assistant_text(&self.messages)
52 }
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct ReActState {
59 pub messages: Vec<Message>,
62 pub steps: usize,
64}
65
66impl ReActState {
67 pub fn from_user(text: impl Into<String>) -> Self {
69 Self {
70 messages: vec![Message::user(text)],
71 steps: 0,
72 }
73 }
74
75 #[must_use]
79 pub fn last_assistant_text(&self) -> Option<String> {
80 last_assistant_text(&self.messages)
81 }
82}
83
84#[derive(Clone, Debug, Default, PartialEq, Eq)]
87pub struct SupervisorState {
88 pub messages: Vec<Message>,
90 pub last_speaker: Option<String>,
93 pub next_speaker: Option<crate::supervisor::SupervisorDecision>,
97}
98
99impl SupervisorState {
100 pub fn from_user(text: impl Into<String>) -> Self {
102 Self {
103 messages: vec![Message::user(text)],
104 last_speaker: None,
105 next_speaker: None,
106 }
107 }
108
109 #[must_use]
113 pub fn last_assistant_text(&self) -> Option<String> {
114 last_assistant_text(&self.messages)
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn assistant(parts: Vec<ContentPart>) -> Message {
123 Message::new(Role::Assistant, parts)
124 }
125
126 fn text(s: &str) -> ContentPart {
127 ContentPart::text(s)
128 }
129
130 #[test]
131 fn returns_none_when_no_assistant_message_exists() {
132 let state = ChatState::from_user("hi");
133 assert_eq!(state.last_assistant_text(), None);
134 }
135
136 #[test]
137 fn concatenates_every_text_part_of_the_last_assistant_message() {
138 let mut state = ChatState::from_user("hi");
140 state
141 .messages
142 .push(assistant(vec![text("first"), text(" "), text("second")]));
143 assert_eq!(state.last_assistant_text(), Some("first second".to_owned()));
144 }
145
146 #[test]
147 fn skips_non_text_content_parts() {
148 let mut state = ReActState::from_user("ask");
151 let tool_use = ContentPart::ToolUse {
152 id: "tu1".into(),
153 name: "calc".into(),
154 input: serde_json::json!({}),
155 provider_echoes: Vec::new(),
156 };
157 state
158 .messages
159 .push(assistant(vec![text("before"), tool_use, text("after")]));
160 assert_eq!(state.last_assistant_text(), Some("beforeafter".to_owned()));
161 }
162
163 #[test]
164 fn returns_none_when_last_assistant_message_has_no_text() {
165 let mut state = SupervisorState::from_user("ask");
168 let tool_use = ContentPart::ToolUse {
169 id: "tu1".into(),
170 name: "calc".into(),
171 input: serde_json::json!({}),
172 provider_echoes: Vec::new(),
173 };
174 state.messages.push(assistant(vec![tool_use]));
175 assert_eq!(state.last_assistant_text(), None);
176 }
177
178 #[test]
179 fn returns_text_from_most_recent_assistant_skipping_earlier_turns() {
180 let mut state = ChatState::from_user("hi");
183 state.messages.push(assistant(vec![text("old")]));
184 state.messages.push(Message::user("follow-up"));
185 state.messages.push(assistant(vec![text("new")]));
186 assert_eq!(state.last_assistant_text(), Some("new".to_owned()));
187 }
188}