use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use agent_framework_core::memory::{ContextProvider, SessionContext};
use agent_framework_core::types::Message;
use agent_framework_redis::{RedisChatMessageStore, RedisContextProvider};
use uuid::Uuid;
enum ServerGuard {
Spawned(Child),
External,
}
impl Drop for ServerGuard {
fn drop(&mut self) {
if let ServerGuard::Spawned(child) = self {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn redis_server_available() -> bool {
Command::new("redis-server")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn free_ephemeral_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
listener.local_addr().expect("local_addr").port()
}
async fn wait_until_ready(url: &str) -> bool {
for _ in 0..50 {
if let Ok(store) = RedisChatMessageStore::new(url, None) {
if store.ping().await {
return true;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
async fn test_server() -> Option<(String, ServerGuard)> {
if let Ok(url) = std::env::var("REDIS_URL") {
return Some((url, ServerGuard::External));
}
if !redis_server_available() {
eprintln!(
"skipping live Redis test: REDIS_URL is not set and no `redis-server` binary was found on PATH"
);
return None;
}
let port = free_ephemeral_port();
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!("agent-framework-redis-it-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create redis-server working dir");
let mut child = Command::new("redis-server")
.args([
"--port",
&port.to_string(),
"--bind",
"127.0.0.1",
"--save",
"",
"--appendonly",
"no",
"--daemonize",
"no",
"--dir",
])
.arg(&dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn redis-server");
let url = format!("redis://127.0.0.1:{port}/0");
if !wait_until_ready(&url).await {
eprintln!("skipping live Redis test: spawned redis-server did not become ready in time");
let _ = child.kill();
let _ = child.wait();
return None;
}
Some((url, ServerGuard::Spawned(child)))
}
fn unique(prefix: &str) -> String {
format!("{prefix}-{}", Uuid::new_v4())
}
async fn redisearch_available(url: &str) -> bool {
let Ok(client) = redis::Client::open(url) else {
return false;
};
let Ok(mut conn) = client.get_multiplexed_async_connection().await else {
return false;
};
redis::cmd("FT._LIST")
.query_async::<Vec<String>>(&mut conn)
.await
.is_ok()
}
#[tokio::test]
async fn chat_message_store_add_list_clear_round_trip() {
let Some((url, _guard)) = test_server().await else {
return;
};
let store = RedisChatMessageStore::new(&url, Some(unique("thread"))).unwrap();
assert!(store.list_messages().await.unwrap().is_empty());
store
.add_messages(vec![
Message::user("Hello"),
Message::assistant("Hi there!"),
])
.await
.unwrap();
let messages = store.list_messages().await.unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].text(), "Hello");
assert_eq!(messages[1].text(), "Hi there!");
store
.add_messages(vec![Message::user("How are you?")])
.await
.unwrap();
let messages = store.list_messages().await.unwrap();
assert_eq!(messages.len(), 3);
assert_eq!(messages[2].text(), "How are you?");
store.clear().await.unwrap();
assert!(store.list_messages().await.unwrap().is_empty());
}
#[tokio::test]
async fn chat_message_store_trims_to_max_messages() {
let Some((url, _guard)) = test_server().await else {
return;
};
let store = RedisChatMessageStore::new(&url, Some(unique("thread")))
.unwrap()
.with_max_messages(3);
for i in 0..5 {
store
.add_messages(vec![Message::user(format!("message {i}"))])
.await
.unwrap();
}
let messages = store.list_messages().await.unwrap();
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].text(), "message 2");
assert_eq!(messages[1].text(), "message 3");
assert_eq!(messages[2].text(), "message 4");
}
#[tokio::test]
async fn chat_message_store_auto_generated_thread_ids_are_isolated() {
let Some((url, _guard)) = test_server().await else {
return;
};
let key_prefix = unique("iso");
let store_a = RedisChatMessageStore::new(&url, None)
.unwrap()
.with_key_prefix(&key_prefix);
let store_b = RedisChatMessageStore::new(&url, None)
.unwrap()
.with_key_prefix(&key_prefix);
assert_ne!(store_a.session_id(), store_b.session_id());
store_a
.add_messages(vec![Message::user("only in A")])
.await
.unwrap();
assert_eq!(store_a.list_messages().await.unwrap().len(), 1);
assert!(store_b.list_messages().await.unwrap().is_empty());
}
#[tokio::test]
async fn chat_message_store_survives_to_dict_round_trip_against_live_server() {
let Some((url, _guard)) = test_server().await else {
return;
};
let thread_id = unique("thread");
let store = RedisChatMessageStore::new(&url, Some(thread_id.clone())).unwrap();
store
.add_messages(vec![Message::user("persisted")])
.await
.unwrap();
let state = store.to_dict();
let restored = RedisChatMessageStore::from_dict(&state).unwrap();
let messages = restored.list_messages().await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].text(), "persisted");
}
#[tokio::test]
async fn chat_message_store_before_run_prepends_history_and_after_run_records_it() {
let Some((url, _guard)) = test_server().await else {
return;
};
let store = RedisChatMessageStore::new(&url, Some(unique("thread"))).unwrap();
let mut ctx = SessionContext::new(vec![Message::user("first turn")]);
store.before_run(&mut ctx).await.unwrap();
assert!(ctx.messages.is_empty());
store
.after_run(
&[Message::user("first turn")],
&[Message::assistant("first reply")],
None,
)
.await
.unwrap();
let mut ctx2 = SessionContext::new(vec![Message::user("second turn")]);
store.before_run(&mut ctx2).await.unwrap();
let texts: Vec<String> = ctx2.messages.iter().map(|m| m.text()).collect();
assert_eq!(
texts,
vec!["first turn".to_string(), "first reply".to_string()]
);
store
.after_run(
&[Message::user("second turn")],
&[],
Some(&agent_framework_core::error::Error::service("boom")),
)
.await
.unwrap();
let messages = store.list_messages().await.unwrap();
assert_eq!(messages.len(), 2);
assert!(store.is_history_provider());
}
#[tokio::test]
async fn context_provider_after_run_then_before_run_surfaces_matching_memory() {
let Some((url, _guard)) = test_server().await else {
return;
};
let provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(unique("ctx"))
.with_user_id("user-it-1");
provider
.after_run(&[Message::user("I love hiking in the Cascades")], &[], None)
.await
.unwrap();
let mut ctx = SessionContext::new(vec![Message::user("Tell me about hiking")]);
provider.before_run(&mut ctx).await.unwrap();
assert_eq!(ctx.messages.len(), 1);
assert!(ctx.messages[0]
.text()
.contains("I love hiking in the Cascades"));
assert!(ctx.messages[0]
.text()
.starts_with("## Memories\nConsider the following memories"));
}
#[tokio::test]
async fn context_provider_before_run_returns_empty_context_when_nothing_matches() {
let Some((url, _guard)) = test_server().await else {
return;
};
let provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(unique("ctx"))
.with_user_id("user-it-2");
provider
.after_run(&[Message::user("I love hiking in the Cascades")], &[], None)
.await
.unwrap();
let mut ctx = SessionContext::new(vec![Message::user("What is the capital of France?")]);
provider.before_run(&mut ctx).await.unwrap();
assert!(ctx.messages.is_empty());
}
#[tokio::test]
async fn context_provider_scopes_memories_by_user_id() {
let Some((url, _guard)) = test_server().await else {
return;
};
let key_prefix = unique("ctx");
let provider_a = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(&key_prefix)
.with_user_id("user-a");
let provider_b = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(&key_prefix)
.with_user_id("user-b");
provider_a
.after_run(
&[Message::user("user-a's secret hobby is pottery")],
&[],
None,
)
.await
.unwrap();
let mut ctx_b = SessionContext::new(vec![Message::user("Tell me about pottery")]);
provider_b.before_run(&mut ctx_b).await.unwrap();
assert!(
ctx_b.messages.is_empty(),
"provider scoped to user-b must not see user-a's memories"
);
let mut ctx_a = SessionContext::new(vec![Message::user("Tell me about pottery")]);
provider_a.before_run(&mut ctx_a).await.unwrap();
assert_eq!(ctx_a.messages.len(), 1);
}
#[tokio::test]
async fn context_provider_session_id_conflict_is_enforced_end_to_end() {
let Some((url, _guard)) = test_server().await else {
return;
};
let provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(unique("ctx"))
.with_user_id("user-it-3")
.with_scope_to_per_operation_thread_id(true);
let mut ctx1 = SessionContext::new(vec![]);
ctx1.session_id = Some("thread-1".to_string());
provider.before_run(&mut ctx1).await.unwrap();
let mut ctx2 = SessionContext::new(vec![]);
ctx2.session_id = Some("thread-2".to_string());
let err = provider.before_run(&mut ctx2).await.unwrap_err();
assert!(err.to_string().contains("only be used with one thread"));
}
#[tokio::test]
async fn context_provider_force_scan_fallback_works_regardless_of_redisearch_availability() {
let Some((url, _guard)) = test_server().await else {
return;
};
let provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(unique("ctx"))
.with_user_id("user-force-fallback")
.with_force_scan_fallback(true);
provider
.after_run(&[Message::user("I love hiking in the Cascades")], &[], None)
.await
.unwrap();
let mut ctx = SessionContext::new(vec![Message::user("Tell me about hiking")]);
provider.before_run(&mut ctx).await.unwrap();
assert_eq!(ctx.messages.len(), 1);
assert!(ctx.messages[0]
.text()
.contains("I love hiking in the Cascades"));
}
#[tokio::test]
async fn context_provider_redisearch_finds_and_excludes_memories_when_available() {
let Some((url, _guard)) = test_server().await else {
return;
};
if !redisearch_available(&url).await {
eprintln!(
"skipping RediSearch-specific test: connected Redis server has no RediSearch \
module loaded (point REDIS_URL at a Redis Stack server to exercise FT.SEARCH)"
);
return;
}
let provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(unique("ftctx"))
.with_user_id("user-ft-1");
provider
.after_run(&[Message::user("I love hiking in the Cascades")], &[], None)
.await
.unwrap();
let mut ctx = SessionContext::new(vec![Message::user("Tell me about hiking")]);
provider.before_run(&mut ctx).await.unwrap();
assert_eq!(ctx.messages.len(), 1);
assert!(ctx.messages[0]
.text()
.contains("I love hiking in the Cascades"));
assert!(ctx.messages[0]
.text()
.starts_with("## Memories\nConsider the following memories"));
let mut ctx_empty = SessionContext::new(vec![Message::user("What is the capital of France?")]);
provider.before_run(&mut ctx_empty).await.unwrap();
assert!(ctx_empty.messages.is_empty());
}
#[tokio::test]
async fn context_provider_redisearch_scopes_memories_by_tag_filter_when_available() {
let Some((url, _guard)) = test_server().await else {
return;
};
if !redisearch_available(&url).await {
eprintln!("skipping RediSearch-specific test: no RediSearch module loaded");
return;
}
let key_prefix = unique("ftctx");
let provider_a = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(&key_prefix)
.with_user_id("user-ft-a");
let provider_b = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(&key_prefix)
.with_user_id("user-ft-b");
provider_a
.after_run(
&[Message::user("user-ft-a's secret hobby is pottery")],
&[],
None,
)
.await
.unwrap();
let mut ctx_b = SessionContext::new(vec![Message::user("Tell me about pottery")]);
provider_b.before_run(&mut ctx_b).await.unwrap();
assert!(
ctx_b.messages.is_empty(),
"provider scoped to user-ft-b must not see user-ft-a's memories via FT.SEARCH's TAG filter"
);
let mut ctx_a = SessionContext::new(vec![Message::user("Tell me about pottery")]);
provider_a.before_run(&mut ctx_a).await.unwrap();
assert_eq!(ctx_a.messages.len(), 1);
}
#[tokio::test]
async fn context_provider_redisearch_respects_limit_when_available() {
let Some((url, _guard)) = test_server().await else {
return;
};
if !redisearch_available(&url).await {
eprintln!("skipping RediSearch-specific test: no RediSearch module loaded");
return;
}
let provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(unique("ftctx"))
.with_user_id("user-ft-limit")
.with_limit(2);
for i in 0..5 {
provider
.after_run(
&[Message::user(format!("apple fact number {i}"))],
&[],
None,
)
.await
.unwrap();
}
let mut ctx = SessionContext::new(vec![Message::user("apple")]);
provider.before_run(&mut ctx).await.unwrap();
assert_eq!(ctx.messages.len(), 1);
let memory_line_count = ctx.messages[0].text().lines().skip(2).count();
assert_eq!(memory_line_count, 2);
}
#[tokio::test]
async fn context_provider_redisearch_entries_are_not_visible_to_forced_scan_fallback() {
let Some((url, _guard)) = test_server().await else {
return;
};
if !redisearch_available(&url).await {
eprintln!("skipping RediSearch-specific test: no RediSearch module loaded");
return;
}
let key_prefix = unique("ftctx");
let ft_provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(&key_prefix)
.with_user_id("user-ft-cross");
let fallback_provider = RedisContextProvider::new(&url)
.unwrap()
.with_key_prefix(&key_prefix)
.with_user_id("user-ft-cross")
.with_force_scan_fallback(true);
ft_provider
.after_run(&[Message::user("stored via JSON.SET")], &[], None)
.await
.unwrap();
let mut ctx = SessionContext::new(vec![Message::user("Tell me about JSON.SET")]);
fallback_provider.before_run(&mut ctx).await.unwrap();
assert!(
ctx.messages.is_empty(),
"SCAN+MGET must not see a JSON.SET-backed entry (documented storage-encoding divergence)"
);
}