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;
49
50/// Append-only handle to a chat-history buffer.
51///
52/// Wraps `&mut Vec<Message>` but exposes only [`append`](Self::append)
53/// and [`view`](Self::view). Nothing can reach the underlying `Vec` to
54/// call `pop`, `truncate`, `clear`, `split_off`, `remove`, or `last_mut`
55/// without going through the explicit (and reviewable) escape hatch
56/// [`Session::messages_mut_unchecked`](crate::session::Session) — which
57/// intentionally does not exist in Phase A.
58///
59/// # Examples
60///
61/// ```rust
62/// use codetether_agent::provider::{ContentPart, Message, Role};
63/// use codetether_agent::session::history::History;
64///
65/// let mut buf: Vec<Message> = Vec::new();
66/// let mut history = History::new(&mut buf);
67/// assert!(history.view().is_empty());
68///
69/// history.append(Message {
70///     role: Role::Assistant,
71///     content: vec![ContentPart::Text {
72///         text: "ok".to_string(),
73///     }],
74/// });
75/// assert_eq!(history.view().len(), 1);
76/// ```
77pub struct History<'a> {
78    buf: &'a mut Vec<Message>,
79}
80
81impl<'a> History<'a> {
82    /// Wrap an existing `Vec<Message>` as an append-only history.
83    ///
84    /// # Arguments
85    ///
86    /// * `buf` — The underlying buffer. Ownership is not taken; the
87    ///   caller keeps the `Vec` but loses the ability to rewrite it
88    ///   while the `History` handle is alive.
89    ///
90    /// # Examples
91    ///
92    /// ```rust
93    /// use codetether_agent::provider::Message;
94    /// use codetether_agent::session::history::History;
95    ///
96    /// let mut buf: Vec<Message> = Vec::new();
97    /// let history = History::new(&mut buf);
98    /// assert!(history.view().is_empty());
99    /// ```
100    pub fn new(buf: &'a mut Vec<Message>) -> Self {
101        Self { buf }
102    }
103
104    /// Append a single message to the end of the history.
105    ///
106    /// The only mutating operation on [`History`] — every other mutation
107    /// must be routed through a dedicated API on the owning type.
108    pub fn append(&mut self, msg: Message) {
109        self.buf.push(msg);
110    }
111
112    /// Borrow the history as an immutable slice for reads.
113    pub fn view(&self) -> &[Message] {
114        self.buf
115    }
116
117    /// The number of entries currently in the history.
118    pub fn len(&self) -> usize {
119        self.buf.len()
120    }
121
122    /// `true` when the history is empty.
123    pub fn is_empty(&self) -> bool {
124        self.buf.is_empty()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::provider::{ContentPart, Role};
132
133    fn text(role: Role, s: &str) -> Message {
134        Message {
135            role,
136            content: vec![ContentPart::Text {
137                text: s.to_string(),
138            }],
139        }
140    }
141
142    #[test]
143    fn append_then_view_is_monotonic() {
144        let mut buf: Vec<Message> = Vec::new();
145        let mut history = History::new(&mut buf);
146
147        assert!(history.is_empty());
148        assert_eq!(history.len(), 0);
149
150        history.append(text(Role::User, "a"));
151        history.append(text(Role::Assistant, "b"));
152        history.append(text(Role::Tool, "c"));
153
154        let view = history.view();
155        assert_eq!(view.len(), 3);
156        assert!(matches!(view[0].role, Role::User));
157        assert!(matches!(view[1].role, Role::Assistant));
158        assert!(matches!(view[2].role, Role::Tool));
159    }
160
161    #[test]
162    fn len_tracks_underlying_vec() {
163        let mut buf: Vec<Message> = vec![text(Role::User, "seed")];
164        let history = History::new(&mut buf);
165        assert_eq!(history.len(), 1);
166        assert!(!history.is_empty());
167    }
168}