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}