refs(#2264): Phase 4 — replace Session.messages: Vec<Message> with AppendLog (#2579)

- Wire AppendLog as the backing store for Session.messages
- Add Deref, From impls, and explicit mutation methods to AppendLog
- Narrow API: remove DerefMut, add push_batch/truncate_to/trim_front/clear/last_mut
- Update all direct message assignments to use .into() conversions
- Update tests to deref through AppendLog for comparisons

Rebased onto upstream/main (v0.8.57) to resolve merge conflicts.
This commit is contained in:
Justin Gao
2026-06-10 16:55:11 +08:00
parent b23067bacd
commit 08904fde47
7 changed files with 84 additions and 36 deletions
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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<Message> = 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);
+1 -1
View File
@@ -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;
}
+5 -4
View File
@@ -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));
+2 -2
View File
@@ -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<Message> = self.session.messages.clone().into();
messages.push(self.runtime_prompt_message());
messages
}
+5 -5
View File
@@ -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<SystemPrompt>,
/// Conversation history (API format)
pub messages: Vec<Message>,
/// 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<Message>) {
self.messages = messages;
self.messages = messages.into();
self.messages_revision = self.messages_revision.saturating_add(1);
}
+61 -14
View File
@@ -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<Message>,
}
#[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<Message>) {
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<Item = &Message> {
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<Message>`.
#[must_use]
pub fn into_inner(self) -> Vec<Message> {
self.messages
}
}
@@ -246,6 +273,26 @@ impl Default for AppendLog {
}
}
impl From<Vec<Message>> for AppendLog {
fn from(messages: Vec<Message>) -> Self {
Self { messages }
}
}
impl From<AppendLog> for Vec<Message> {
fn from(log: AppendLog) -> Self {
log.messages
}
}
impl std::ops::Deref for AppendLog {
type Target = Vec<Message>;
fn deref(&self) -> &Self::Target {
&self.messages
}
}
// ── TurnScratch ────────────────────────────────────────────────────────
/// Per-turn ephemeral data. Cleared at every turn boundary.