Skip to main content

codetether_agent/session/
history.rs

1//! Append-only view of a session's chat history.
2//!
3//! The core Phase A invariant is that [`Session::messages`] is the pure
4//! record of *what happened* and is never mutated by compression,
5//! experimental dedup / snippet strategies, or pairing repair. This
6//! module provides a typestate-style wrapper, [`History`], whose only
7//! mutating method is `append` — making it a compile-time error for a
8//! new caller to acquire a `&mut Vec<Message>` and rewrite the buffer
9//! in place.
10//!
11//! ## Scope
12//!
13//! This is a *foundation* for the visibility tightening described in the
14//! refactor plan. The public [`Session::messages`] field stays `pub` for
15//! now so the crate keeps building across every caller (19 files, ~51
16//! usages at the time of this refactor). New code paths should reach for
17//! [`Session::history`] instead. Future PRs can tighten `messages` to
18//! `pub(crate)` — each remaining direct-mutation site then surfaces as
19//! a compile error and is migrated onto this typestate.
20//!
21//! ## Examples
22//!
23//! ```rust,no_run
24//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
25//! use codetether_agent::session::Session;
26//!
27//! let session = Session::new().await.unwrap();
28//! let view = session.history();
29//! assert!(view.is_empty());
30//! # });
31//! ```
32//!
33//! ```rust
34//! use codetether_agent::provider::{ContentPart, Message, Role};
35//! use codetether_agent::session::history::History;
36//!
37//! let mut buf: Vec<Message> = Vec::new();
38//! let mut history = History::new(&mut buf);
39//! history.append(Message {
40//!     role: Role::User,
41//!     content: vec![ContentPart::Text {
42//!         text: "hello".to_string(),
43//!     }],
44//! });
45//! assert_eq!(history.view().len(), 1);
46//! ```
47
48use crate::provider::Message;
49use crate::session::pages::{PageKind, classify, classify_all};
50
51/// Append-only handle to a chat-history buffer.
52///
53/// Wraps `&mut Vec<Message>` but exposes only [`append`](Self::append)
54/// and [`view`](Self::view). Nothing can reach the underlying `Vec` to
55/// call `pop`, `truncate`, `clear`, `split_off`, `remove`, or `last_mut`
56/// without going through the explicit (and reviewable) escape hatch
57/// [`Session::messages_mut_unchecked`](crate::session::Session) — which
58/// intentionally does not exist in Phase A.
59///
60/// # Examples
61///
62/// ```rust
63/// use codetether_agent::provider::{ContentPart, Message, Role};
64/// use codetether_agent::session::history::History;
65///
66/// let mut buf: Vec<Message> = Vec::new();
67/// let mut history = History::new(&mut buf);
68/// assert!(history.view().is_empty());
69///
70/// history.append(Message {
71///     role: Role::Assistant,
72///     content: vec![ContentPart::Text {
73///         text: "ok".to_string(),
74///     }],
75/// });
76/// assert_eq!(history.view().len(), 1);
77/// ```
78pub struct History<'a> {
79    buf: &'a mut Vec<Message>,
80    pages: Option<&'a mut Vec<PageKind>>,
81}
82
83impl<'a> History<'a> {
84    /// Wrap an existing `Vec<Message>` as an append-only history.
85    ///
86    /// # Arguments
87    ///
88    /// * `buf` — The underlying buffer. Ownership is not taken; the
89    ///   caller keeps the `Vec` but loses the ability to rewrite it
90    ///   while the `History` handle is alive.
91    ///
92    /// # Examples
93    ///
94    /// ```rust
95    /// use codetether_agent::provider::Message;
96    /// use codetether_agent::session::history::History;
97    ///
98    /// let mut buf: Vec<Message> = Vec::new();
99    /// let history = History::new(&mut buf);
100    /// assert!(history.view().is_empty());
101    /// ```
102    pub fn new(buf: &'a mut Vec<Message>) -> Self {
103        Self { buf, pages: None }
104    }
105
106    /// Wrap a history buffer plus its parallel page sidecar.
107    pub(crate) fn with_pages(buf: &'a mut Vec<Message>, pages: &'a mut Vec<PageKind>) -> Self {
108        Self {
109            buf,
110            pages: Some(pages),
111        }
112    }
113
114    /// Append a single message to the end of the history.
115    ///
116    /// The only mutating operation on [`History`] — every other mutation
117    /// must be routed through a dedicated API on the owning type.
118    pub fn append(&mut self, msg: Message) {
119        if let Some(pages) = self.pages.as_deref_mut() {
120            if pages.len() != self.buf.len() {
121                *pages = classify_all(self.buf);
122            }
123            pages.push(classify(&msg));
124        }
125        self.buf.push(msg);
126    }
127
128    /// Borrow the history as an immutable slice for reads.
129    pub fn view(&self) -> &[Message] {
130        self.buf
131    }
132
133    /// The number of entries currently in the history.
134    pub fn len(&self) -> usize {
135        self.buf.len()
136    }
137
138    /// `true` when the history is empty.
139    pub fn is_empty(&self) -> bool {
140        self.buf.is_empty()
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::provider::{ContentPart, Role};
148
149    fn text(role: Role, s: &str) -> Message {
150        Message {
151            role,
152            content: vec![ContentPart::Text {
153                text: s.to_string(),
154            }],
155        }
156    }
157
158    #[test]
159    fn append_then_view_is_monotonic() {
160        let mut buf: Vec<Message> = Vec::new();
161        let mut history = History::new(&mut buf);
162
163        assert!(history.is_empty());
164        assert_eq!(history.len(), 0);
165
166        history.append(text(Role::User, "a"));
167        history.append(text(Role::Assistant, "b"));
168        history.append(text(Role::Tool, "c"));
169
170        let view = history.view();
171        assert_eq!(view.len(), 3);
172        assert!(matches!(view[0].role, Role::User));
173        assert!(matches!(view[1].role, Role::Assistant));
174        assert!(matches!(view[2].role, Role::Tool));
175    }
176
177    #[test]
178    fn len_tracks_underlying_vec() {
179        let mut buf: Vec<Message> = vec![text(Role::User, "seed")];
180        let history = History::new(&mut buf);
181        assert_eq!(history.len(), 1);
182        assert!(!history.is_empty());
183    }
184}