Skip to main content

agent_framework_core/
history.rs

1//! Conversation-history context providers.
2//!
3//! Upstream moved conversation history out of the thread/session entirely:
4//! it is now just another [`ContextProvider`] — a [`HistoryProvider`] —
5//! that prepends its stored messages ahead of a run (`before_run`) and
6//! records the run's request + response messages after a successful run
7//! (`after_run`). [`InMemoryHistoryProvider`] is the in-process default;
8//! [`FileHistoryProvider`] persists to a JSON file on disk.
9//!
10//! [`Agent`](crate::agent::Agent) and
11//! [`WorkflowAgent`](crate::workflow::WorkflowAgent) auto-attach a fresh
12//! [`InMemoryHistoryProvider`] (via [`ensure_history_provider`]) to any
13//! non-service-managed [`AgentSession`] that doesn't already carry a history
14//! provider, so local multi-turn conversations keep accumulating history the
15//! way the old `AgentThread` message store used to.
16
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19
20use async_trait::async_trait;
21use serde_json::Value;
22
23use crate::error::{Error, Result};
24use crate::memory::{ContextProvider, SessionContext};
25use crate::session::AgentSession;
26use crate::types::Message;
27
28/// A [`ContextProvider`] that also manages conversation history.
29///
30/// This is a marker trait (over and above [`ContextProvider::is_history_provider`],
31/// which drives runtime detection via trait objects): implementing it
32/// documents that a type's `before_run`/`after_run` are the ones responsible
33/// for a session's conversation history, distinguishing it from a generic
34/// memory/RAG provider.
35pub trait HistoryProvider: ContextProvider {}
36
37/// The identity a message is matched on when aligning an incoming run against
38/// already-stored history.
39///
40/// Mirrors upstream's `get_message_identity`: a message that carries a
41/// (non-empty) `message_id` is identified by it alone, and one that does not is
42/// identified by its role plus its contents. The two forms never compare equal, so an
43/// id-bearing message is never confused with an id-less one that happens to
44/// carry the same text.
45#[derive(PartialEq)]
46enum MessageIdentity<'a> {
47    Id(&'a str),
48    Contents(&'a crate::types::Role, &'a [crate::types::Content]),
49}
50
51fn message_identity(message: &Message) -> MessageIdentity<'_> {
52    match real_message_id(message) {
53        Some(id) => MessageIdentity::Id(id),
54        None => MessageIdentity::Contents(&message.role, &message.contents),
55    }
56}
57
58/// A message's id when it actually identifies something.
59///
60/// An empty string is not an identity: every message carrying one would
61/// compare equal to every other, whatever its role or contents. The rest of the
62/// crate already reads an empty id as absent — see the `!id.is_empty()` guard
63/// in `agent::response_to_updates`'s `keep_provider_ids` — and matching that
64/// here keeps a provider or caller that emits `Some("")` from collapsing an
65/// entire conversation into one identity.
66fn real_message_id(message: &Message) -> Option<&str> {
67    message.message_id.as_deref().filter(|id| !id.is_empty())
68}
69
70/// Return the suffix of `incoming` that is not already present in `existing`,
71/// so replaying a conversation does not store — or resend — it twice.
72///
73/// A caller that keeps its own transcript and replays all of it on every turn
74/// (the AG-UI shape, and any client that tracks history itself) hands back
75/// everything the provider already stored. Appending that unconditionally grows
76/// history superlinearly — each turn re-storing the whole conversation on top of
77/// the copy already there — and prepending it unconditionally sends every
78/// replayed turn to the model twice.
79///
80/// **`incoming` must be messages that could be a replay** — a run's *input*.
81/// Response messages were just generated and can never be a replay of stored
82/// history, so they are never passed here: see [`new_run_messages`], which
83/// aligns the input and appends the responses unconditionally. Aligning over
84/// input and responses together would let a response that happens to reproduce
85/// the stored tail swallow the genuinely new turn in front of it.
86///
87/// The stored run is located inside `incoming` by matching every message by
88/// `MessageIdentity`; the messages after that block are the new ones. Where
89/// it is looked for depends on what the provider holds, which is why
90/// [`StoredHistory`] is a parameter rather than a guess: a complete history can
91/// only be matched at offset `0`, while a trimmed window has to be searched
92/// for. This function assumes [`StoredHistory::Complete`]; a windowed store
93/// should call [`filter_new_messages_from`] instead.
94///
95/// When no alignment is found, **all** of `incoming` is returned: appending is
96/// the behavior every provider had before this function existed, so a
97/// conversation this cannot align is stored exactly as it used to be.
98///
99/// Two deliberate divergences from upstream, both refusing to drop a turn that
100/// might be real:
101///
102/// - Upstream's fallback, when alignment fails, deduplicates by identity
103///   against a set of everything stored. That drops a legitimately repeated
104///   turn — two identical, id-less `"yes"` replies in one conversation collapse
105///   to one, and the second turn's user message is lost from history
106///   permanently. Here an unalignable run is simply appended.
107/// - An alignment that consumes *all* of `incoming`, leaving nothing new, is
108///   not treated as an alignment. Input that exactly repeats the stored tail is
109///   ambiguous — a replay carrying no new turn, or a turn that genuinely
110///   repeated itself verbatim — and it is read as the latter, which is what the
111///   providers did before this function existed. Upstream reads it the other
112///   way and stores nothing.
113pub fn filter_new_messages<'a>(existing: &[Message], incoming: &'a [Message]) -> &'a [Message] {
114    filter_new_messages_from(existing, incoming, StoredHistory::Complete)
115}
116
117/// What a provider's stored history is, which decides *which* occurrence of it
118/// inside a replayed transcript is the one it actually holds.
119///
120/// The distinction only bites when the stored run occurs more than once in the
121/// replay — a conversation that repeats an exchange verbatim — and the two
122/// answers are opposites, so it is a caller's decision rather than a guess.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum StoredHistory {
125    /// Everything the conversation has said so far, in order: the provider
126    /// drops nothing. The **first** occurrence is therefore the stored one, and
127    /// everything after it is new — matching a later occurrence would discard
128    /// the genuinely new turns in between.
129    Complete,
130    /// A retention-limited store's list, which *may* be a trimmed window of
131    /// the most recent messages.
132    ///
133    /// A match at the start still wins, because a list that has not actually
134    /// been trimmed yet — a first write that happened to fill the cap exactly —
135    /// is still the complete conversation, and treating it as a window would
136    /// drop the turns between two occurrences of it. Only when the stored run
137    /// is *not* at the start is it searched for, and then the **last**
138    /// occurrence is the retained window: matching an earlier one would re-send
139    /// the whole middle of the transcript on every turn, messages the store is
140    /// going to trim away again anyway.
141    ///
142    /// The ambiguous case — a genuine window whose content also opens the
143    /// transcript — resolves to the anchored match, so it re-sends the middle
144    /// rather than risking a lost turn. That is the right way round: the
145    /// redundant writes are trimmed away, a dropped turn is not recoverable.
146    Window,
147}
148
149/// [`filter_new_messages`] with an explicit [`StoredHistory`] shape.
150pub fn filter_new_messages_from<'a>(
151    existing: &[Message],
152    incoming: &'a [Message],
153    shape: StoredHistory,
154) -> &'a [Message] {
155    if existing.is_empty() || incoming.len() <= existing.len() || !could_be_a_replay(existing) {
156        return incoming;
157    }
158    let matches = |start: usize| {
159        incoming[start..start + existing.len()]
160            .iter()
161            .zip(existing)
162            .all(|(a, b)| message_identity(a) == message_identity(b))
163    };
164    let found = match shape {
165        // A complete history starts at the conversation's first message, so a
166        // replay of it can only *begin* with it. Matching at a later offset
167        // would mean the input carried turns from before the conversation
168        // started — impossible — and a coincidental match there would silently
169        // drop every genuinely new message in front of it.
170        StoredHistory::Complete => matches(0).then_some(0),
171        // A window may sit in the middle of the transcript, so it has to be
172        // searched for — but an anchored match still wins, since an at-cap list
173        // that has never actually been trimmed is still a complete history.
174        StoredHistory::Window => matches(0).then_some(0).or_else(|| {
175            (1..(incoming.len() - existing.len()))
176                .rev()
177                .find(|s| matches(*s))
178        }),
179    };
180    match found {
181        Some(start) => &incoming[start + existing.len()..],
182        None => incoming,
183    }
184}
185
186/// Whether stored history could be a *replay* at all when it turns up inside a
187/// run's input, or whether a match could only ever be a coincidence.
188///
189/// Matching on content alone cannot tell a replayed transcript from new input
190/// that happens to repeat it — with stored `[user("yes")]`, an input of
191/// `[user("yes"), user("question")]` is equally well a caller replaying its one
192/// stored turn or a caller saying "yes" again and asking something. Treating it
193/// as a replay drops a real turn; treating it as new duplicates one. Neither
194/// content nor length separates them, so this asks what kind of evidence the
195/// stored messages carry:
196///
197/// - **A message id.** Ids are assigned, not guessed, so an id that matches is
198///   the replayed message, full stop.
199/// - **A non-user turn.** A replay is a *transcript*: it carries the assistant
200///   (and tool) turns the conversation produced. A caller sending genuinely new
201///   input sends its own turns, which are user messages — it does not compose
202///   the assistant's replies. So stored history that is nothing but id-less user
203///   messages is never read as a replay.
204///
205/// The cost is declining to deduplicate a genuine replay of a user-only,
206/// id-less history — a store whose retention window happens to hold no
207/// assistant turn, say — which then appends exactly as it did before any of
208/// this existed. That is the safe direction: a redundant write is trimmed away,
209/// a dropped turn is not recoverable.
210fn could_be_a_replay(existing: &[Message]) -> bool {
211    existing
212        .iter()
213        .any(|m| real_message_id(m).is_some() || m.role != crate::types::Role::user())
214}
215
216/// What a run adds to `existing`: the part of its **input** that is not a
217/// replay of already-stored history (see [`filter_new_messages`]), followed by
218/// **every** response message.
219///
220/// Splitting the two is load-bearing. Responses are generated by the run that
221/// is reporting them, so they cannot be a replay of anything — but they can
222/// coincidentally reproduce the stored tail. Aligning over the concatenation
223/// would let that coincidence match, and the genuinely new input in front of it
224/// would be dropped along with the stored block: stored `[q, a]` plus a new run
225/// whose input is `q` and whose response opens with `a` would store neither.
226pub fn new_run_messages(
227    existing: &[Message],
228    request_messages: &[Message],
229    response_messages: &[Message],
230) -> Vec<Message> {
231    new_run_messages_from(
232        existing,
233        request_messages,
234        response_messages,
235        StoredHistory::Complete,
236    )
237}
238
239/// [`new_run_messages`] for a provider whose stored history has a known
240/// [`StoredHistory`] shape — a retention-limited store holds a
241/// [`StoredHistory::Window`].
242pub fn new_run_messages_from(
243    existing: &[Message],
244    request_messages: &[Message],
245    response_messages: &[Message],
246    shape: StoredHistory,
247) -> Vec<Message> {
248    filter_new_messages_from(existing, request_messages, shape)
249        .iter()
250        .chain(response_messages)
251        .cloned()
252        .collect()
253}
254
255/// Inject `stored` ahead of any context another provider has already added —
256/// unless the run's own input already carries that stored run, which is
257/// exactly what a caller replaying its own transcript sends.
258///
259/// Storing only the new suffix (see [`new_run_messages`]) stops history growing
260/// on a replay, but the request is assembled the other way round: the agent
261/// sends `ctx.messages` followed by `ctx.input_messages`. For a replaying
262/// caller those hold the same turns, so injecting unconditionally sends the
263/// model `q1, a1, q1, a1, q2` — every replayed turn twice, on every subsequent
264/// run — even though this provider stored each of them only once. When the
265/// input aligns against the stored run it is a superset of it, and injecting
266/// nothing leaves the request complete.
267pub fn inject_stored_history(ctx: &mut SessionContext, stored: Vec<Message>) {
268    inject_stored_history_from(ctx, stored, StoredHistory::Complete)
269}
270
271/// [`inject_stored_history`] for a provider whose stored history has a known
272/// [`StoredHistory`] shape — a retention-limited store holds a
273/// [`StoredHistory::Window`].
274pub fn inject_stored_history_from(
275    ctx: &mut SessionContext,
276    stored: Vec<Message>,
277    shape: StoredHistory,
278) {
279    if stored.is_empty() {
280        return;
281    }
282    // A shorter result means the input aligned against — and therefore already
283    // contains — the stored run.
284    if filter_new_messages_from(&stored, &ctx.input_messages, shape).len()
285        < ctx.input_messages.len()
286    {
287        return;
288    }
289    let existing = std::mem::take(&mut ctx.messages);
290    ctx.messages = stored.into_iter().chain(existing).collect();
291}
292
293/// Attach a fresh [`InMemoryHistoryProvider`] as the **first** context
294/// provider on `session` when it is not service-managed and does not already
295/// carry a history provider. A no-op for service-managed sessions (the
296/// service owns history server-side) and for sessions that already have one
297/// attached (detected via [`ContextProvider::is_history_provider`]).
298pub fn ensure_history_provider(session: &mut AgentSession) {
299    if session.service_session_id().is_none()
300        && !session
301            .context_providers
302            .iter()
303            .any(|p| p.is_history_provider())
304    {
305        session
306            .context_providers
307            .insert(0, Arc::new(InMemoryHistoryProvider::new()));
308    }
309}
310
311/// In-memory [`HistoryProvider`]: keeps history in an `Arc<Mutex<Vec<Message>>>`,
312/// shared across clones.
313#[derive(Default, Clone)]
314pub struct InMemoryHistoryProvider {
315    messages: Arc<Mutex<Vec<Message>>>,
316}
317
318impl InMemoryHistoryProvider {
319    /// An empty history provider.
320    pub fn new() -> Self {
321        Self::default()
322    }
323
324    /// A history provider seeded with `messages`.
325    pub fn with_messages(messages: Vec<Message>) -> Self {
326        Self {
327            messages: Arc::new(Mutex::new(messages)),
328        }
329    }
330
331    /// The stored messages, in chronological order.
332    pub fn list_messages(&self) -> Vec<Message> {
333        self.messages.lock().unwrap().clone()
334    }
335
336    /// Serialize the stored history to `{"messages": [...]}`.
337    pub fn to_dict(&self) -> Value {
338        serde_json::json!({ "messages": self.list_messages() })
339    }
340
341    /// Reconstruct a provider from state produced by [`InMemoryHistoryProvider::to_dict`].
342    pub fn from_dict(state: &Value) -> Result<Self> {
343        let messages = match state.get("messages") {
344            Some(v) if !v.is_null() => serde_json::from_value(v.clone()).map_err(|e| {
345                Error::Serialization(format!("failed to restore history provider: {e}"))
346            })?,
347            _ => Vec::new(),
348        };
349        Ok(Self::with_messages(messages))
350    }
351}
352
353#[async_trait]
354impl ContextProvider for InMemoryHistoryProvider {
355    async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
356        let stored = self.messages.lock().unwrap().clone();
357        inject_stored_history(ctx, stored);
358        Ok(())
359    }
360
361    async fn after_run(
362        &self,
363        request_messages: &[Message],
364        response_messages: &[Message],
365        error: Option<&Error>,
366    ) -> Result<()> {
367        if error.is_none() {
368            let mut guard = self.messages.lock().unwrap();
369            let new = new_run_messages(&guard, request_messages, response_messages);
370            guard.extend(new);
371        }
372        Ok(())
373    }
374
375    fn is_history_provider(&self) -> bool {
376        true
377    }
378}
379
380impl HistoryProvider for InMemoryHistoryProvider {}
381
382/// A [`HistoryProvider`] that persists to a JSON file on disk, loading any
383/// existing history from `path` on construction and rewriting the whole file
384/// after every successful run.
385///
386/// Persistence is **atomic and concurrency-safe**: `after_run` serializes the
387/// whole append→snapshot→write sequence behind an async `write_lock` (shared
388/// across clones), writes to a temporary sibling file, and atomically renames
389/// it into place. The in-memory history is only updated *after* the on-disk
390/// write succeeds, so a failed write never diverges memory from disk, and two
391/// concurrent runs sharing cloned providers can't lose each other's messages
392/// via a snapshot/overwrite race.
393#[derive(Clone)]
394pub struct FileHistoryProvider {
395    path: PathBuf,
396    messages: Arc<Mutex<Vec<Message>>>,
397    /// Serializes the append+snapshot+persist critical section across all
398    /// clones so concurrent `after_run` calls can't interleave into a lost
399    /// update. Held only in `after_run`; reads (`before_run`/`list_messages`)
400    /// take the fast in-memory `messages` lock and never block on this.
401    write_lock: Arc<tokio::sync::Mutex<()>>,
402}
403
404impl FileHistoryProvider {
405    /// Open (or create) a file-backed history provider at `path`. A missing
406    /// or empty file starts with no history; an existing file is parsed
407    /// eagerly, so a malformed file fails the constructor rather than
408    /// silently discarding history.
409    pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
410        let path = path.into();
411        let messages = if path.exists() {
412            let data = std::fs::read_to_string(&path)
413                .map_err(|e| Error::other(format!("failed to read history file {path:?}: {e}")))?;
414            if data.trim().is_empty() {
415                Vec::new()
416            } else {
417                let value: Value = serde_json::from_str(&data).map_err(|e| {
418                    Error::Serialization(format!("failed to parse history file {path:?}: {e}"))
419                })?;
420                match value.get("messages") {
421                    Some(v) if !v.is_null() => serde_json::from_value(v.clone()).map_err(|e| {
422                        Error::Serialization(format!("failed to parse history file {path:?}: {e}"))
423                    })?,
424                    _ => Vec::new(),
425                }
426            }
427        } else {
428            Vec::new()
429        };
430        Ok(Self {
431            path,
432            messages: Arc::new(Mutex::new(messages)),
433            write_lock: Arc::new(tokio::sync::Mutex::new(())),
434        })
435    }
436
437    /// The path this provider persists to.
438    pub fn path(&self) -> &std::path::Path {
439        &self.path
440    }
441
442    /// The stored messages, in chronological order.
443    pub fn list_messages(&self) -> Vec<Message> {
444        self.messages.lock().unwrap().clone()
445    }
446
447    /// Serialize the stored history to `{"messages": [...]}`.
448    pub fn to_dict(&self) -> Value {
449        serde_json::json!({ "messages": self.list_messages() })
450    }
451
452    /// Write `messages` as `{"messages": [...]}` via a temp-file-plus-rename so
453    /// the destination is replaced **atomically**: serialize, write to a
454    /// uniquely named temporary sibling file, then rename it over the
455    /// destination. The rename is atomic on a POSIX filesystem, so a reader
456    /// (or a crash) sees either the old file or the fully-written new one,
457    /// never a truncated file.
458    ///
459    /// This guarantees atomic *replacement*, not fsync-level crash durability:
460    /// like the sibling checkpoint writer, it does not `sync_all` the file or
461    /// its directory, so a power loss immediately after the rename may still
462    /// lose the last write. That is an intentional trade-off for these small,
463    /// frequently-rewritten history files.
464    async fn persist(&self, messages: &[Message]) -> Result<()> {
465        let dict = serde_json::json!({ "messages": messages });
466        let json = serde_json::to_string_pretty(&dict)
467            .map_err(|e| Error::Serialization(format!("failed to serialize history: {e}")))?;
468        // Temp file in the same directory so `rename` stays on one filesystem.
469        // A uuid suffix keeps two providers on the same path from clobbering
470        // each other's temp file.
471        let file_name = self
472            .path
473            .file_name()
474            .and_then(|f| f.to_str())
475            .unwrap_or("history.json");
476        let tmp = self
477            .path
478            .with_file_name(format!("{file_name}.tmp.{}", uuid::Uuid::new_v4()));
479        if let Err(e) = tokio::fs::write(&tmp, &json).await {
480            // Don't leave the partial temp file behind on a failed write.
481            let _ = tokio::fs::remove_file(&tmp).await;
482            return Err(Error::other(format!(
483                "failed to write history temp file {tmp:?}: {e}"
484            )));
485        }
486        tokio::fs::rename(&tmp, &self.path).await.map_err(|e| {
487            // Best-effort cleanup of the temp file on a failed rename.
488            let tmp = tmp.clone();
489            tokio::spawn(async move {
490                let _ = tokio::fs::remove_file(&tmp).await;
491            });
492            Error::other(format!(
493                "failed to finalize history file {:?}: {e}",
494                self.path
495            ))
496        })
497    }
498}
499
500#[async_trait]
501impl ContextProvider for FileHistoryProvider {
502    async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
503        let stored = self.messages.lock().unwrap().clone();
504        inject_stored_history(ctx, stored);
505        Ok(())
506    }
507
508    async fn after_run(
509        &self,
510        request_messages: &[Message],
511        response_messages: &[Message],
512        error: Option<&Error>,
513    ) -> Result<()> {
514        if error.is_some() {
515            return Ok(());
516        }
517        // Serialize the whole append→snapshot→persist sequence so two
518        // concurrent runs (sharing cloned providers) can't interleave a
519        // snapshot and an overwrite into a lost update.
520        let _write = self.write_lock.lock().await;
521
522        // Compute the next full history WITHOUT committing it to shared memory
523        // yet: disk is the source of truth. We persist first and only update
524        // the in-memory copy on success, so a failed write leaves memory and
525        // disk consistent (the run's `after_run` returns the error and the
526        // caller can retry) rather than diverging.
527        let snapshot = {
528            let guard = self.messages.lock().unwrap();
529            let mut next = guard.clone();
530            next.extend(new_run_messages(
531                &guard,
532                request_messages,
533                response_messages,
534            ));
535            next
536        };
537        self.persist(&snapshot).await?;
538        *self.messages.lock().unwrap() = snapshot;
539        Ok(())
540    }
541
542    fn is_history_provider(&self) -> bool {
543        true
544    }
545}
546
547impl HistoryProvider for FileHistoryProvider {}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::types::Message;
553
554    #[tokio::test]
555    async fn before_run_prepends_stored_messages_ahead_of_existing_context_messages() {
556        let provider = InMemoryHistoryProvider::with_messages(vec![
557            Message::user("q1"),
558            Message::assistant("a1"),
559        ]);
560        let mut ctx = SessionContext::new(vec![Message::user("q2")]);
561        ctx.messages
562            .push(Message::system("injected by another provider"));
563        provider.before_run(&mut ctx).await.unwrap();
564        let texts: Vec<String> = ctx.messages.iter().map(|m| m.text()).collect();
565        assert_eq!(
566            texts,
567            vec![
568                "q1".to_string(),
569                "a1".to_string(),
570                "injected by another provider".to_string(),
571            ]
572        );
573    }
574
575    #[tokio::test]
576    async fn after_run_appends_only_on_success() {
577        let provider = InMemoryHistoryProvider::new();
578        provider
579            .after_run(&[Message::user("hi")], &[Message::assistant("hello")], None)
580            .await
581            .unwrap();
582        assert_eq!(provider.list_messages().len(), 2);
583
584        // A failed run must not record anything.
585        provider
586            .after_run(
587                &[Message::user("again")],
588                &[],
589                Some(&Error::service("boom")),
590            )
591            .await
592            .unwrap();
593        assert_eq!(provider.list_messages().len(), 2);
594    }
595
596    #[test]
597    fn to_dict_from_dict_round_trips_messages() {
598        let provider = InMemoryHistoryProvider::with_messages(vec![
599            Message::user("q1"),
600            Message::assistant("a1"),
601        ]);
602        let state = provider.to_dict();
603        let restored = InMemoryHistoryProvider::from_dict(&state).unwrap();
604        let msgs = restored.list_messages();
605        assert_eq!(msgs.len(), 2);
606        assert_eq!(msgs[0].text(), "q1");
607        assert_eq!(msgs[1].text(), "a1");
608    }
609
610    #[test]
611    fn from_dict_tolerates_a_missing_messages_key() {
612        let restored = InMemoryHistoryProvider::from_dict(&serde_json::json!({})).unwrap();
613        assert!(restored.list_messages().is_empty());
614    }
615
616    #[test]
617    fn ensure_history_provider_attaches_once_and_skips_service_managed() {
618        let mut local = AgentSession::new();
619        ensure_history_provider(&mut local);
620        assert_eq!(local.context_providers.len(), 1);
621        assert!(local.context_providers[0].is_history_provider());
622        // A second call must not attach a duplicate.
623        ensure_history_provider(&mut local);
624        assert_eq!(local.context_providers.len(), 1);
625
626        let mut service = AgentSession::service("svc-1");
627        ensure_history_provider(&mut service);
628        assert!(service.context_providers.is_empty());
629    }
630
631    #[tokio::test]
632    async fn file_history_provider_persists_and_reloads() {
633        let dir = std::env::temp_dir().join(format!("afr-history-test-{}", uuid::Uuid::new_v4()));
634        std::fs::create_dir_all(&dir).unwrap();
635        let path = dir.join("history.json");
636
637        let provider = FileHistoryProvider::new(&path).unwrap();
638        assert!(provider.list_messages().is_empty());
639        provider
640            .after_run(&[Message::user("hi")], &[Message::assistant("hello")], None)
641            .await
642            .unwrap();
643        assert_eq!(provider.list_messages().len(), 2);
644
645        // A fresh provider opened on the same path picks up the persisted
646        // history.
647        let reloaded = FileHistoryProvider::new(&path).unwrap();
648        let msgs = reloaded.list_messages();
649        assert_eq!(msgs.len(), 2);
650        assert_eq!(msgs[0].text(), "hi");
651        assert_eq!(msgs[1].text(), "hello");
652
653        std::fs::remove_dir_all(&dir).ok();
654    }
655
656    #[tokio::test]
657    async fn file_history_provider_concurrent_runs_do_not_lose_messages() {
658        // Regression for the snapshot/overwrite race: many concurrent
659        // `after_run` calls on cloned providers must all be durably recorded,
660        // and the on-disk file must always be valid JSON (atomic rename).
661        let dir = std::env::temp_dir().join(format!("afr-history-conc-{}", uuid::Uuid::new_v4()));
662        std::fs::create_dir_all(&dir).unwrap();
663        let path = dir.join("history.json");
664
665        let provider = FileHistoryProvider::new(&path).unwrap();
666        const N: usize = 50;
667        let mut handles = Vec::new();
668        for i in 0..N {
669            let p = provider.clone();
670            handles.push(tokio::spawn(async move {
671                p.after_run(
672                    &[Message::user(format!("q{i}"))],
673                    &[Message::assistant(format!("a{i}"))],
674                    None,
675                )
676                .await
677                .unwrap();
678            }));
679        }
680        for h in handles {
681            h.await.unwrap();
682        }
683
684        // Every run contributed a request + response message; none lost.
685        assert_eq!(provider.list_messages().len(), N * 2);
686
687        // The on-disk file is valid and holds the full history (atomic rename
688        // means it is never a torn/partial write).
689        let reloaded = FileHistoryProvider::new(&path).unwrap();
690        assert_eq!(reloaded.list_messages().len(), N * 2);
691
692        // No temp files left behind.
693        let leftover: Vec<_> = std::fs::read_dir(&dir)
694            .unwrap()
695            .filter_map(|e| e.ok())
696            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
697            .collect();
698        assert!(leftover.is_empty(), "temp files leaked: {leftover:?}");
699
700        std::fs::remove_dir_all(&dir).ok();
701    }
702
703    fn texts(messages: &[Message]) -> Vec<String> {
704        messages.iter().map(Message::text).collect()
705    }
706
707    #[test]
708    fn filter_new_messages_returns_everything_when_nothing_is_stored() {
709        let incoming = vec![Message::user("hi"), Message::assistant("hello")];
710        assert_eq!(filter_new_messages(&[], &incoming).len(), 2);
711    }
712
713    #[test]
714    fn filter_new_messages_drops_a_replayed_prefix() {
715        let existing = vec![Message::user("hi"), Message::assistant("hello")];
716        let incoming = vec![
717            Message::user("hi"),
718            Message::assistant("hello"),
719            Message::user("more"),
720            Message::assistant("sure"),
721        ];
722        assert_eq!(
723            texts(filter_new_messages(&existing, &incoming)),
724            vec!["more".to_string(), "sure".to_string()]
725        );
726    }
727
728    /// A provider with a retention limit stores a *window* of the
729    /// conversation, so the replayed transcript starts before what is stored
730    /// and the window has to be searched for.
731    #[test]
732    fn filter_new_messages_aligns_a_trimmed_window() {
733        let existing = vec![Message::user("q2"), Message::assistant("a2")];
734        let incoming = vec![
735            Message::user("q1"),
736            Message::assistant("a1"),
737            Message::user("q2"),
738            Message::assistant("a2"),
739            Message::user("q3"),
740        ];
741        assert_eq!(
742            texts(filter_new_messages_from(
743                &existing,
744                &incoming,
745                StoredHistory::Window
746            )),
747            vec!["q3".to_string()]
748        );
749        // A *complete* history can only be a replay when it comes first, so the
750        // same input is entirely new to a store that keeps everything.
751        assert_eq!(filter_new_messages(&existing, &incoming).len(), 5);
752    }
753
754    /// A retention-limited list whose length merely reaches the cap has not
755    /// necessarily been trimmed — a first write that filled it exactly is still
756    /// the complete conversation. The anchored match therefore wins even for a
757    /// `Window`, or the turns between two occurrences would be lost
758    /// (PR #16 review).
759    #[test]
760    fn a_window_prefers_an_anchored_match_over_a_later_one() {
761        let existing = vec![Message::user("q"), Message::assistant("a")];
762        let incoming = vec![
763            Message::user("q"),
764            Message::assistant("a"),
765            Message::user("filler"),
766            Message::user("q"),
767            Message::assistant("a"),
768            Message::user("new"),
769        ];
770        assert_eq!(
771            texts(filter_new_messages_from(
772                &existing,
773                &incoming,
774                StoredHistory::Window
775            )),
776            vec![
777                "filler".to_string(),
778                "q".to_string(),
779                "a".to_string(),
780                "new".to_string()
781            ],
782            "an at-cap list that opens the replay is a complete history, not a window"
783        );
784
785        // A genuine window — one that does *not* open the replay — is still
786        // found by searching, and the last occurrence is the retained one.
787        let earlier = vec![
788            Message::user("q0"),
789            Message::assistant("a0"),
790            Message::user("q"),
791            Message::assistant("a"),
792            Message::user("filler"),
793            Message::user("q"),
794            Message::assistant("a"),
795            Message::user("new"),
796        ];
797        assert_eq!(
798            texts(filter_new_messages_from(
799                &existing,
800                &earlier,
801                StoredHistory::Window
802            )),
803            vec!["new".to_string()]
804        );
805    }
806
807    /// An empty string is not an identity — every message carrying one would
808    /// otherwise compare equal to every other (PR #16 review).
809    #[test]
810    fn an_empty_message_id_is_not_an_identity() {
811        let empty_id = |m: Message| Message {
812            message_id: Some(String::new()),
813            ..m
814        };
815        // Two unrelated messages, both with empty ids, must not align.
816        let existing = vec![
817            empty_id(Message::user("q")),
818            empty_id(Message::assistant("a")),
819        ];
820        let incoming = vec![
821            empty_id(Message::user("something else")),
822            empty_id(Message::assistant("unrelated")),
823            empty_id(Message::user("new")),
824        ];
825        assert_eq!(
826            texts(filter_new_messages(&existing, &incoming)).len(),
827            3,
828            "empty ids must fall back to role and contents, not match everything"
829        );
830
831        // And an empty id is not evidence of a replay either: user-only stored
832        // history carrying one is still left alone.
833        let user_only = vec![empty_id(Message::user("yes"))];
834        let repeat = vec![empty_id(Message::user("yes")), Message::user("question")];
835        assert_eq!(texts(filter_new_messages(&user_only, &repeat)).len(), 2);
836    }
837
838    /// Stored history that is nothing but id-less user turns cannot be told
839    /// apart from new input that repeats it, so it is never read as a replay
840    /// (PR #16 review).
841    #[test]
842    fn id_less_user_only_history_is_never_read_as_a_replay() {
843        let existing = vec![Message::user("yes")];
844        let incoming = vec![Message::user("yes"), Message::user("question")];
845        assert_eq!(
846            texts(filter_new_messages(&existing, &incoming)),
847            vec!["yes".to_string(), "question".to_string()],
848            "saying 'yes' again is not a replay of having said it"
849        );
850
851        // The same shape *with* the assistant's reply in the stored history is
852        // a transcript, and a caller sending it back is replaying.
853        let with_reply = vec![Message::user("yes"), Message::assistant("go on")];
854        let replayed = vec![
855            Message::user("yes"),
856            Message::assistant("go on"),
857            Message::user("question"),
858        ];
859        assert_eq!(
860            texts(filter_new_messages(&with_reply, &replayed)),
861            vec!["question".to_string()]
862        );
863
864        // And a message id is evidence on its own, user-only or not.
865        let with_id = vec![Message {
866            message_id: Some("m1".to_string()),
867            ..Message::user("yes")
868        }];
869        let replayed_by_id = vec![
870            Message {
871                message_id: Some("m1".to_string()),
872                ..Message::user("yes")
873            },
874            Message::user("question"),
875        ];
876        assert_eq!(
877            texts(filter_new_messages(&with_id, &replayed_by_id)),
878            vec!["question".to_string()]
879        );
880    }
881
882    /// A complete history matched at a later offset would drop every genuinely
883    /// new message in front of the coincidence (PR #16 review).
884    #[test]
885    fn a_complete_history_never_matches_past_the_start() {
886        let existing = vec![Message::user("yes")];
887        let incoming = vec![
888            Message::user("preface"),
889            Message::user("yes"),
890            Message::user("question"),
891        ];
892        assert_eq!(
893            texts(filter_new_messages(&existing, &incoming)),
894            vec![
895                "preface".to_string(),
896                "yes".to_string(),
897                "question".to_string()
898            ],
899            "nothing may be dropped: the stored 'yes' is not where this input starts"
900        );
901    }
902
903    /// A store that keeps everything takes the anchored match, so a repeat in
904    /// the middle of a replay is a genuinely new turn it has never stored.
905    #[test]
906    fn a_complete_store_keeps_the_turns_between_two_occurrences() {
907        let existing = vec![Message::user("q"), Message::assistant("a")];
908        let incoming = vec![
909            Message::user("q"),
910            Message::assistant("a"),
911            Message::user("filler"),
912            Message::user("q"),
913            Message::assistant("a"),
914            Message::user("new"),
915        ];
916        assert_eq!(
917            texts(filter_new_messages_from(
918                &existing,
919                &incoming,
920                StoredHistory::Complete
921            )),
922            vec![
923                "filler".to_string(),
924                "q".to_string(),
925                "a".to_string(),
926                "new".to_string()
927            ]
928        );
929    }
930    /// The deliberate divergence from upstream: when the stored run cannot be
931    /// aligned, everything is stored — never a set-based dedup that would drop
932    /// a legitimately repeated turn.
933    #[test]
934    fn filter_new_messages_keeps_a_repeated_turn_it_cannot_align() {
935        let existing = vec![Message::user("ping"), Message::assistant("pong")];
936        let incoming = vec![Message::user("ping"), Message::assistant("pong!")];
937        assert_eq!(
938            texts(filter_new_messages(&existing, &incoming)),
939            vec!["ping".to_string(), "pong!".to_string()]
940        );
941    }
942
943    #[test]
944    fn filter_new_messages_matches_on_message_id_when_present() {
945        let with_id = |m: Message, id: &str| Message {
946            message_id: Some(id.to_string()),
947            ..m
948        };
949        let stored = with_id(Message::user("hi"), "m1");
950        // Same id, different text: the id alone decides.
951        let replayed = with_id(Message::user("edited after the fact"), "m1");
952        let incoming = vec![replayed, Message::assistant("hello")];
953        assert_eq!(
954            texts(filter_new_messages(
955                std::slice::from_ref(&stored),
956                &incoming
957            )),
958            vec!["hello".to_string()]
959        );
960
961        // Different id, same text: a distinct message, so nothing aligns.
962        let other = vec![with_id(Message::user("hi"), "m2")];
963        assert_eq!(
964            filter_new_messages(std::slice::from_ref(&stored), &other).len(),
965            1
966        );
967    }
968
969    /// The bug this guards: a caller that keeps its own transcript and replays
970    /// all of it every turn used to have the whole conversation re-stored on
971    /// top of the copy already there, growing history superlinearly and
972    /// resending the duplicates to the model on the next run.
973    #[tokio::test]
974    async fn replaying_the_transcript_does_not_duplicate_stored_history() {
975        let provider = InMemoryHistoryProvider::new();
976        provider
977            .after_run(&[Message::user("q1")], &[Message::assistant("a1")], None)
978            .await
979            .unwrap();
980
981        // Turn two: the caller replays everything it has, plus the new turn.
982        provider
983            .after_run(
984                &[
985                    Message::user("q1"),
986                    Message::assistant("a1"),
987                    Message::user("q2"),
988                ],
989                &[Message::assistant("a2")],
990                None,
991            )
992            .await
993            .unwrap();
994
995        assert_eq!(
996            texts(&provider.list_messages()),
997            vec![
998                "q1".to_string(),
999                "a1".to_string(),
1000                "q2".to_string(),
1001                "a2".to_string()
1002            ]
1003        );
1004    }
1005
1006    /// A response that happens to reproduce the stored tail must not be
1007    /// mistaken for a replay of it: alignment sees the run's input only, and
1008    /// response messages are always appended (PR #16 review).
1009    #[tokio::test]
1010    async fn a_response_repeating_stored_history_is_still_stored() {
1011        let provider = InMemoryHistoryProvider::with_messages(vec![
1012            Message::user("q"),
1013            Message::assistant("a"),
1014        ]);
1015        // A genuinely new turn whose input repeats "q" and whose (tool-loop)
1016        // response opens with "a" — concatenated, that is exactly the stored
1017        // run followed by one new message.
1018        provider
1019            .after_run(
1020                &[Message::user("q")],
1021                &[Message::assistant("a"), Message::assistant("b")],
1022                None,
1023            )
1024            .await
1025            .unwrap();
1026        assert_eq!(
1027            texts(&provider.list_messages()),
1028            vec![
1029                "q".to_string(),
1030                "a".to_string(),
1031                "q".to_string(),
1032                "a".to_string(),
1033                "b".to_string()
1034            ],
1035            "the new turn must not be swallowed by its own response"
1036        );
1037    }
1038
1039    /// `before_run` is the other half of the replay problem: prepending stored
1040    /// history to input that already contains it sends every replayed turn to
1041    /// the model twice (PR #16 review).
1042    #[tokio::test]
1043    async fn before_run_does_not_prepend_history_the_input_already_carries() {
1044        let provider = InMemoryHistoryProvider::with_messages(vec![
1045            Message::user("q1"),
1046            Message::assistant("a1"),
1047        ]);
1048
1049        // The agent sends `ctx.messages` followed by `ctx.input_messages`, so
1050        // a replaying caller's input already carries the stored run and this
1051        // provider must contribute nothing.
1052        let mut replayed = SessionContext::new(vec![
1053            Message::user("q1"),
1054            Message::assistant("a1"),
1055            Message::user("q2"),
1056        ]);
1057        provider.before_run(&mut replayed).await.unwrap();
1058        assert!(
1059            replayed.messages.is_empty(),
1060            "stored history must not be injected on top of a replay of itself: {:?}",
1061            texts(&replayed.messages)
1062        );
1063
1064        // A caller that tracks nothing itself still gets history injected.
1065        let mut incremental = SessionContext::new(vec![Message::user("q2")]);
1066        provider.before_run(&mut incremental).await.unwrap();
1067        assert_eq!(
1068            texts(&incremental.messages),
1069            vec!["q1".to_string(), "a1".to_string()]
1070        );
1071    }
1072
1073    /// The append-only path — the shape the agent itself produces — is
1074    /// untouched, including a turn that repeats an earlier one verbatim (the
1075    /// case an alignment consuming all of `incoming` would otherwise swallow).
1076    #[tokio::test]
1077    async fn append_only_runs_still_accumulate_every_turn() {
1078        let provider = InMemoryHistoryProvider::new();
1079        for _ in 0..3 {
1080            provider
1081                .after_run(
1082                    &[Message::user("ping")],
1083                    &[Message::assistant("pong")],
1084                    None,
1085                )
1086                .await
1087                .unwrap();
1088        }
1089        assert_eq!(provider.list_messages().len(), 6);
1090    }
1091
1092    #[tokio::test]
1093    async fn file_history_provider_does_not_duplicate_a_replayed_transcript() {
1094        let dir = std::env::temp_dir().join(format!("afr-history-dedup-{}", uuid::Uuid::new_v4()));
1095        std::fs::create_dir_all(&dir).unwrap();
1096        let path = dir.join("history.json");
1097
1098        let provider = FileHistoryProvider::new(&path).unwrap();
1099        provider
1100            .after_run(&[Message::user("q1")], &[Message::assistant("a1")], None)
1101            .await
1102            .unwrap();
1103        provider
1104            .after_run(
1105                &[
1106                    Message::user("q1"),
1107                    Message::assistant("a1"),
1108                    Message::user("q2"),
1109                ],
1110                &[Message::assistant("a2")],
1111                None,
1112            )
1113            .await
1114            .unwrap();
1115
1116        assert_eq!(texts(&provider.list_messages()).len(), 4);
1117        // Disk agrees with memory.
1118        let reloaded = FileHistoryProvider::new(&path).unwrap();
1119        assert_eq!(
1120            texts(&reloaded.list_messages()),
1121            vec![
1122                "q1".to_string(),
1123                "a1".to_string(),
1124                "q2".to_string(),
1125                "a2".to_string()
1126            ]
1127        );
1128
1129        std::fs::remove_dir_all(&dir).ok();
1130    }
1131}