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}