diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 098b00eb..306f76cf 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -226,7 +226,7 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { } }; - app.api_messages.clone_from(&session.messages); + app.api_messages = session.messages.clone().into(); app.clear_history(); let cells_to_add: Vec<_> = app .api_messages diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6f89fe19..ba9a2f88 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1302,7 +1302,7 @@ impl Engine { } else if messages.is_empty() && system_prompt.is_none() { self.session.id = uuid::Uuid::new_v4().to_string(); } - self.session.messages = messages; + self.session.messages = messages.into(); self.session.compaction_summary_prompt = extract_compaction_summary_prompt(system_prompt.clone()); self.session.system_prompt = system_prompt; @@ -1351,7 +1351,7 @@ impl Engine { } } if let Some(idx) = cut { - self.session.messages.truncate(idx); + self.session.messages.truncate_to(idx); self.session.bump_messages_revision(); } // Now dispatch the new message as a normal send, @@ -1398,7 +1398,7 @@ impl Engine { .tx_event .send(Event::SessionUpdated { session_id: self.session.id.clone(), - messages: self.session.messages.clone(), + messages: self.session.messages.clone().into(), system_prompt: self.session.system_prompt.clone(), model: self.session.model.clone(), workspace: self.session.workspace.clone(), @@ -1938,7 +1938,7 @@ impl Engine { Ok(result) => { if !result.messages.is_empty() || self.session.messages.is_empty() { let messages_after = result.messages.len(); - self.session.messages = result.messages; + self.session.messages = result.messages.into(); self.merge_compaction_summary(result.summary_prompt); self.emit_session_updated().await; let removed = messages_before.saturating_sub(messages_after); @@ -2034,7 +2034,7 @@ impl Engine { { Ok(result) => { let messages_after = result.messages.len(); - self.session.messages = result.messages; + self.session.messages = result.messages.into(); self.emit_session_updated().await; let summary = format!( @@ -2088,7 +2088,7 @@ impl Engine { while self.session.messages.len() > MIN_RECENT_MESSAGES_TO_KEEP && self.estimated_input_tokens() > target_input_budget { - self.session.messages.remove(0); + self.session.messages.trim_front(1); self.session.bump_messages_revision(); removed = removed.saturating_add(1); } @@ -2110,7 +2110,7 @@ impl Engine { let mut retries_used = 0u32; let mut summary_prompt = None; - let mut compacted_messages = self.session.messages.clone(); + let mut compacted_messages: Vec = self.session.messages.clone().into(); let mut forced_config = self.config.compaction.clone(); forced_config.enabled = true; @@ -2145,7 +2145,7 @@ impl Engine { } if !compacted_messages.is_empty() || self.session.messages.is_empty() { - self.session.messages = compacted_messages; + self.session.messages = compacted_messages.into(); } self.merge_compaction_summary(summary_prompt); @@ -2219,7 +2219,7 @@ impl Engine { self.session.model.clone(), self.session.workspace.clone(), self.session.system_prompt.clone(), - self.session.messages.clone(), + self.session.messages.clone().into(), )) .with_cancel_token(self.cancel_token.clone()) .with_trusted_external_paths(trusted_external_paths); diff --git a/crates/tui/src/core/engine/capacity_flow.rs b/crates/tui/src/core/engine/capacity_flow.rs index 514ad120..3385f1e7 100644 --- a/crates/tui/src/core/engine/capacity_flow.rs +++ b/crates/tui/src/core/engine/capacity_flow.rs @@ -415,7 +415,7 @@ impl Engine { { Ok(result) => { if !result.messages.is_empty() || self.session.messages.is_empty() { - self.session.messages = result.messages; + self.session.messages = result.messages.into(); self.merge_compaction_summary(result.summary_prompt); refreshed = true; } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 212042c4..95e752be 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1799,12 +1799,13 @@ fn runtime_prompt_is_projected_without_persisting_to_session_messages() { text: "summary after compaction".to_string(), cache_control: None, }], - }]; + }] + .into(); let stored = engine.session.messages.clone(); let request_messages = engine.messages_with_turn_metadata(); - assert_eq!(engine.session.messages, stored); + assert_eq!(&*engine.session.messages, &*stored); assert_eq!(request_messages.len(), stored.len() + 1); assert!( request_messages @@ -2488,7 +2489,7 @@ fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() { let first_request = engine.messages_with_turn_metadata(); assert_eq!( &first_request[..engine.session.messages.len()], - engine.session.messages.as_slice() + &engine.session.messages[..] ); assert_eq!(first_request.len(), engine.session.messages.len() + 1); assert_eq!(first_request.first(), Some(&first_user)); @@ -2514,7 +2515,7 @@ fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() { let second_request = engine.messages_with_turn_metadata(); assert_eq!( &second_request[..engine.session.messages.len()], - engine.session.messages.as_slice() + &engine.session.messages[..] ); assert_eq!(second_request.len(), engine.session.messages.len() + 1); assert_eq!(second_request.first(), Some(&first_user)); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 53cdf927..afb1a9b0 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -153,7 +153,7 @@ impl Engine { // Only update if we got valid messages (never corrupt state) if !result.messages.is_empty() || self.session.messages.is_empty() { let auto_messages_after = result.messages.len(); - self.session.messages = result.messages; + self.session.messages = result.messages.into(); self.merge_compaction_summary(result.summary_prompt); self.emit_session_updated().await; let removed = auto_messages_before.saturating_sub(auto_messages_after); @@ -2312,7 +2312,7 @@ impl Engine { // messages. This preserves the stable prefix through all stored // messages while avoiding strict chat templates that only allow // system messages at messages[0]. - let mut messages = self.session.messages.clone(); + let mut messages: Vec = self.session.messages.clone().into(); messages.push(self.runtime_prompt_message()); messages } diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 67ffb7ad..a0cd4d94 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -5,7 +5,7 @@ use crate::models::{Message, SystemPrompt, Usage}; use crate::prefix_cache::PrefixStabilityManager; use crate::project_context::{ProjectContext, load_project_context_with_parents}; -use crate::prompt_zones::FrozenPrefix; +use crate::prompt_zones::{AppendLog, FrozenPrefix}; use crate::tui::approval::ApprovalMode; use crate::working_set::WorkingSet; use std::path::PathBuf; @@ -40,8 +40,8 @@ pub struct Session { /// Persisted summary blocks generated by context compaction. pub compaction_summary_prompt: Option, - /// Conversation history (API format) - pub messages: Vec, + /// Conversation history (API format), backed by AppendLog (#2264). + pub messages: AppendLog, /// Total tokens used in this session pub total_usage: SessionUsage, @@ -145,7 +145,7 @@ impl Session { system_prompt: None, system_prompt_override: false, compaction_summary_prompt: None, - messages: Vec::new(), + messages: AppendLog::new(), total_usage: SessionUsage::default(), allow_shell, trust_mode, @@ -179,7 +179,7 @@ impl Session { /// invalidate atomically. #[allow(dead_code)] pub fn replace_messages(&mut self, messages: Vec) { - self.messages = messages; + self.messages = messages.into(); self.messages_revision = self.messages_revision.saturating_add(1); } diff --git a/crates/tui/src/prompt_zones.rs b/crates/tui/src/prompt_zones.rs index 581ac355..e9c9c04a 100644 --- a/crates/tui/src/prompt_zones.rs +++ b/crates/tui/src/prompt_zones.rs @@ -195,16 +195,17 @@ impl std::fmt::Display for PrefixDrift { // ── AppendLog ────────────────────────────────────────────────────────── -/// Append-only conversation history. Only exposes `push`-style mutations. +/// Append-only conversation history. Derefs to `&[Message]` via +/// [`Deref`](std::ops::Deref) for transparent read access; mutations go +/// through explicit methods (`push`, `truncate_to`, `trim_front`, `clear`) +/// whose names make cache impact obvious. /// -/// **Phase 1 scaffolding** — not yet wired into the engine request path. -#[allow(dead_code)] +/// Phase 4: backing store for `Session.messages` (#2264). #[derive(Debug, Clone)] pub struct AppendLog { messages: Vec, } -#[allow(dead_code)] impl AppendLog { pub fn new() -> Self { Self { @@ -216,27 +217,53 @@ impl AppendLog { Self { messages } } + /// Append a message to the log. A single-message push is the cheapest + /// mutation for prefix-cache stability — it extends the byte sequence + /// without disturbing earlier turns. pub fn push(&mut self, message: Message) { self.messages.push(message); } - #[must_use] - pub fn len(&self) -> usize { - self.messages.len() + /// Append multiple messages in one operation (fewer cache-line + /// invalidations than repeated `push`). + pub fn push_batch(&mut self, batch: Vec) { + self.messages.extend(batch); } - #[must_use] - pub fn is_empty(&self) -> bool { - self.messages.is_empty() + /// Truncate to keep only the most recent `new_len` messages. + /// Discards older messages (and their prefix-cache contribution) + /// from the front. + pub fn truncate_to(&mut self, new_len: usize) { + self.messages.truncate(new_len); } - pub fn iter(&self) -> impl Iterator { - self.messages.iter() + /// Remove `count` messages from the front (oldest first). + /// Cache-destroying: drops the prefix that earlier turns share. + pub fn trim_front(&mut self, count: usize) { + if count >= self.messages.len() { + self.messages.clear(); + } else { + self.messages.drain(0..count); + } } + /// Remove all messages. Resets cache state completely. + pub fn clear(&mut self) { + self.messages.clear(); + } + + /// Return a mutable reference to the last message, if any. + /// Prefer this over `last_mut()` on the inner vec — the name signals + /// that only the most recent turn's content is being modified. #[must_use] - pub fn as_slice(&self) -> &[Message] { - &self.messages + pub fn last_mut(&mut self) -> Option<&mut Message> { + self.messages.last_mut() + } + + /// Consume and return the inner `Vec`. + #[must_use] + pub fn into_inner(self) -> Vec { + self.messages } } @@ -246,6 +273,26 @@ impl Default for AppendLog { } } +impl From> for AppendLog { + fn from(messages: Vec) -> Self { + Self { messages } + } +} + +impl From for Vec { + fn from(log: AppendLog) -> Self { + log.messages + } +} + +impl std::ops::Deref for AppendLog { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.messages + } +} + // ── TurnScratch ──────────────────────────────────────────────────────── /// Per-turn ephemeral data. Cleared at every turn boundary.