zeph_core/
session_resume.rs1use zeph_llm::provider::{Message, Role};
15
16#[derive(Debug, Clone)]
21pub struct SessionResumeInfo {
22 pub is_resume: bool,
24 pub prior_message_count: usize,
26 pub prior_turn_count: usize,
28 pub last_active: Option<String>,
30}
31
32impl SessionResumeInfo {
33 #[must_use]
52 pub fn from_messages(messages: &[Message], last_active_raw: Option<&str>) -> Self {
53 let non_system: Vec<&Message> =
54 messages.iter().filter(|m| m.role != Role::System).collect();
55 let prior_turn_count = non_system.iter().filter(|m| m.role == Role::User).count();
56 Self {
57 is_resume: !non_system.is_empty(),
58 prior_message_count: non_system.len(),
59 prior_turn_count,
60 last_active: last_active_raw.and_then(format_last_active),
61 }
62 }
63
64 #[must_use]
70 pub fn banner_text(&self) -> Option<String> {
71 if !self.is_resume {
72 return None;
73 }
74 let turns = if self.prior_turn_count == 1 {
75 "1 turn".to_owned()
76 } else {
77 format!("{} turns", self.prior_turn_count)
78 };
79 let messages = if self.prior_message_count == 1 {
80 "1 message".to_owned()
81 } else {
82 format!("{} messages", self.prior_message_count)
83 };
84 Some(match &self.last_active {
85 Some(last_active) => format!(
86 "\u{21bb} Resuming session (last active {last_active}) — {messages}, {turns}. Type /history to view."
87 ),
88 None => {
89 format!("\u{21bb} Resuming session — {messages}, {turns}. Type /history to view.")
90 }
91 })
92 }
93}
94
95fn format_last_active(raw: &str) -> Option<String> {
99 let parsed = chrono::NaiveDateTime::parse_from_str(raw.trim(), "%Y-%m-%d %H:%M:%S")
100 .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw.trim(), "%Y-%m-%dT%H:%M:%S%.f"))
101 .ok()?;
102 let then = parsed.and_utc();
103 let now = chrono::Utc::now();
104 let secs = now.signed_duration_since(then).num_seconds().max(0);
105 Some(if secs < 60 {
106 "just now".to_owned()
107 } else if secs < 3600 {
108 format!("{}m ago", secs / 60)
109 } else if secs < 86400 {
110 format!("{}h ago", secs / 3600)
111 } else {
112 format!("{}d ago", secs / 86400)
113 })
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use zeph_llm::provider::MessagePart;
120
121 fn msg(role: Role, content: &str) -> Message {
122 Message::from_legacy(role, content)
123 }
124
125 #[test]
126 fn fresh_conversation_system_only_is_not_resume() {
127 let messages = vec![msg(Role::System, "system prompt")];
128 let info = SessionResumeInfo::from_messages(&messages, None);
129 assert!(!info.is_resume);
130 assert_eq!(info.prior_message_count, 0);
131 assert!(info.banner_text().is_none());
132 }
133
134 #[test]
135 fn normal_conversation_is_resume() {
136 let messages = vec![
137 msg(Role::System, "system prompt"),
138 msg(Role::User, "hello"),
139 msg(Role::Assistant, "hi there"),
140 ];
141 let info = SessionResumeInfo::from_messages(&messages, None);
142 assert!(info.is_resume);
143 assert_eq!(info.prior_message_count, 2);
144 assert_eq!(info.prior_turn_count, 1);
145 assert!(info.banner_text().unwrap().contains("Resuming session"));
146 }
147
148 #[test]
152 fn mid_tool_loop_interruption_is_still_resume() {
153 let mut tool_use_only = msg(Role::Assistant, "");
154 tool_use_only.parts.push(MessagePart::ToolUse {
155 id: "toolu_1".to_owned(),
156 name: "bash".to_owned(),
157 input: serde_json::json!({"command": "ls"}),
158 });
159 let mut tool_result = msg(Role::User, "");
160 tool_result.parts.push(MessagePart::ToolResult {
161 tool_use_id: "toolu_1".to_owned(),
162 content: "file.txt".to_owned(),
163 is_error: false,
164 });
165 let messages = vec![
166 msg(Role::System, "system prompt"),
167 tool_use_only,
168 tool_result,
169 ];
170 let info = SessionResumeInfo::from_messages(&messages, None);
171 assert!(
172 info.is_resume,
173 "mid-tool-loop interruption must evaluate as resume, not fresh"
174 );
175 assert_eq!(info.prior_message_count, 2);
176 }
177
178 #[test]
179 fn last_active_formats_recent_seconds_as_just_now() {
180 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
181 let formatted = format_last_active(&now).expect("must parse");
182 assert_eq!(formatted, "just now");
183 }
184
185 #[test]
186 fn last_active_unparsable_returns_none() {
187 assert!(format_last_active("not-a-date").is_none());
188 }
189
190 #[test]
191 fn banner_singular_turn_and_message_grammar() {
192 let messages = vec![msg(Role::System, "sp"), msg(Role::User, "hi")];
193 let info = SessionResumeInfo::from_messages(&messages, None);
194 let text = info.banner_text().unwrap();
195 assert!(text.contains("1 message"));
196 assert!(text.contains("1 turn"));
197 assert!(!text.contains("1 turns"));
198 }
199}