Skip to main content

zeph_core/
session_resume.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Resume-visibility presentation primitive (spec-068 §13).
5//!
6//! [`SessionResumeInfo`] is computed once at hydration time from the already-reconstructed
7//! message stream (`ReplayEngine::fold`'s output, or the `SQLite` fallback when
8//! `[session] enabled = false`) and rendered per-channel: a neutral banner on CLI/TUI
9//! (display-owning channels), nothing on chat/ACP channels (spec-068 §13.2, §13.8).
10//!
11//! This module adds zero I/O: it only inspects a message slice already materialized by the
12//! caller's hydration path.
13
14use zeph_llm::provider::{Message, Role};
15
16/// Presentation-only summary of a conversation-session's prior state, computed at hydration.
17///
18/// Carries no full message vector — expansion is pulled lazily by `/history`, never eagerly
19/// (spec-068 §13.3).
20#[derive(Debug, Clone)]
21pub struct SessionResumeInfo {
22    /// Whether this hydration reconstructed a non-empty prior conversation (§13.4).
23    pub is_resume: bool,
24    /// Count of non-system messages in the reconstructed stream.
25    pub prior_message_count: usize,
26    /// Approximate number of turns (count of user messages in the reconstructed stream).
27    pub prior_turn_count: usize,
28    /// Last-active timestamp, formatted for display (e.g. `"2h ago"`), when known.
29    pub last_active: Option<String>,
30}
31
32impl SessionResumeInfo {
33    /// Compute resume info from an already-reconstructed message stream.
34    ///
35    /// `is_resume` is evaluated against the raw message stream — at least one non-system
36    /// message (`User`, `Assistant`, or a tool-bearing message) makes this `true`, even when
37    /// the only assistant message is tool-use-only with no visible text (spec-068 §13.4,
38    /// AC-17). This must never be computed against a display-filtered/visible-text-only turn
39    /// count — that would false-negative a session interrupted mid-tool-loop as fresh.
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// use zeph_core::session_resume::SessionResumeInfo;
45    /// use zeph_llm::provider::{Message, Role};
46    ///
47    /// let messages = vec![Message::from_legacy(Role::System, "system prompt")];
48    /// let info = SessionResumeInfo::from_messages(&messages, None);
49    /// assert!(!info.is_resume, "system-prompt-only history is fresh, not a resume");
50    /// ```
51    #[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    /// Render the neutral CLI/TUI banner line (spec-068 §13.5).
65    ///
66    /// Carries no interrupted/clean-exit qualifier in v1 (§13.10). Returns `None` when
67    /// `is_resume` is `false` — callers must not render anything for a fresh conversation
68    /// (AC-16).
69    #[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
95/// Format a `SQLite`/`PostgreSQL` `updated_at` string (`"YYYY-MM-DD HH:MM:SS"`, UTC) as a
96/// coarse relative-time string (e.g. `"2h ago"`). Returns `None` if the string cannot be
97/// parsed rather than surfacing a raw, potentially confusing timestamp.
98fn 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    /// Regression test for the M-REV2-2 predicate fix (spec-068 §13.4, AC-17): a session
149    /// interrupted mid-tool-loop reconstructs to `[system, assistant(tool_use-only),
150    /// user(tool_result)]` — no assistant text — and must still evaluate `is_resume = true`.
151    #[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}