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}