use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use swink_agent::{AgentMessage, CustomMessageRegistry};
use crate::entry::SessionEntry;
use crate::interrupt::InterruptState;
use crate::load_options::LoadOptions;
use crate::meta::SessionMeta;
use crate::search::{SessionHit, SessionSearchOptions};
pub type SessionStoreFuture<'a, T> = Pin<Box<dyn Future<Output = io::Result<T>> + Send + 'a>>;
fn spawn_store_call<T: Send + 'static>(
f: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> SessionStoreFuture<'static, T> {
Box::pin(async move {
tokio::task::spawn_blocking(f)
.await
.map_err(io::Error::other)?
})
}
pub struct BlockingSessionStore<S: crate::store::SessionStore + 'static> {
inner: Arc<S>,
registry: Option<Arc<CustomMessageRegistry>>,
}
impl<S: crate::store::SessionStore + 'static> BlockingSessionStore<S> {
pub fn new(store: S) -> Self {
Self {
inner: Arc::new(store),
registry: None,
}
}
#[must_use]
pub fn with_registry(mut self, registry: Arc<CustomMessageRegistry>) -> Self {
self.registry = Some(registry);
self
}
}
fn clone_messages_for_blocking(messages: &[AgentMessage]) -> Vec<AgentMessage> {
swink_agent::clone_messages_for_send(messages)
}
impl<S: crate::store::SessionStore + 'static> BlockingSessionStore<S> {
pub fn save(
&self,
id: &str,
meta: &SessionMeta,
messages: &[AgentMessage],
) -> SessionStoreFuture<'_, ()> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let meta = meta.clone();
let messages = clone_messages_for_blocking(messages);
spawn_store_call(move || inner.save(&id, &meta, &messages))
}
pub fn save_full(
&self,
id: &str,
meta: &SessionMeta,
messages: &[AgentMessage],
state: &serde_json::Value,
) -> SessionStoreFuture<'_, SessionMeta> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let meta = meta.clone();
let messages = clone_messages_for_blocking(messages);
let state = state.clone();
spawn_store_call(move || inner.save_full(&id, &meta, &messages, &state))
}
pub fn append(&self, id: &str, messages: &[AgentMessage]) -> SessionStoreFuture<'_, ()> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let messages = clone_messages_for_blocking(messages);
spawn_store_call(move || inner.append(&id, &messages))
}
pub fn load(&self, id: &str) -> SessionStoreFuture<'_, (SessionMeta, Vec<AgentMessage>)> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let registry = self.registry.clone();
spawn_store_call(move || inner.load(&id, registry.as_deref()))
}
pub fn load_full(
&self,
id: &str,
) -> SessionStoreFuture<'_, (SessionMeta, Vec<AgentMessage>, Option<serde_json::Value>)> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let registry = self.registry.clone();
spawn_store_call(move || inner.load_full(&id, registry.as_deref()))
}
pub fn list(&self) -> SessionStoreFuture<'_, Vec<SessionMeta>> {
let inner = Arc::clone(&self.inner);
spawn_store_call(move || inner.list())
}
pub fn delete(&self, id: &str) -> SessionStoreFuture<'_, ()> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
spawn_store_call(move || inner.delete(&id))
}
pub fn save_state(&self, id: &str, state: &serde_json::Value) -> SessionStoreFuture<'_, ()> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let state = state.clone();
spawn_store_call(move || inner.save_state(&id, &state))
}
pub fn load_state(&self, id: &str) -> SessionStoreFuture<'_, Option<serde_json::Value>> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
spawn_store_call(move || inner.load_state(&id))
}
pub fn save_interrupt(&self, id: &str, state: &InterruptState) -> SessionStoreFuture<'_, ()> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let state = state.clone();
spawn_store_call(move || inner.save_interrupt(&id, &state))
}
pub fn load_interrupt(&self, id: &str) -> SessionStoreFuture<'_, Option<InterruptState>> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
spawn_store_call(move || inner.load_interrupt(&id))
}
pub fn clear_interrupt(&self, id: &str) -> SessionStoreFuture<'_, ()> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
spawn_store_call(move || inner.clear_interrupt(&id))
}
pub fn load_with_options(
&self,
id: &str,
options: &LoadOptions,
) -> SessionStoreFuture<'_, (SessionMeta, Vec<SessionEntry>)> {
let inner = Arc::clone(&self.inner);
let id = id.to_string();
let options = options.clone();
spawn_store_call(move || inner.load_with_options(&id, &options))
}
pub fn search(
&self,
query: &str,
options: &SessionSearchOptions,
) -> SessionStoreFuture<'_, Vec<SessionHit>> {
let inner = Arc::clone(&self.inner);
let query = query.to_string();
let options = options.clone();
spawn_store_call(move || inner.search(&query, &options))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jsonl::JsonlSessionStore;
use crate::time::now_utc;
use swink_agent::AgentMessage;
#[tokio::test]
async fn blocking_session_store_adapter_works() {
let dir = tempfile::tempdir().unwrap();
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let async_store = BlockingSessionStore::new(jsonl_store);
let now = now_utc();
let meta = SessionMeta {
id: "test_async".to_string(),
title: "Async test".to_string(),
created_at: now,
updated_at: now,
version: 1,
sequence: 0,
};
let messages: Vec<AgentMessage> = vec![];
async_store
.save("test_async", &meta, &messages)
.await
.unwrap();
let sessions = async_store.list().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "test_async");
assert_eq!(sessions[0].title, "Async test");
let (loaded_meta, loaded_messages) = async_store.load("test_async").await.unwrap();
assert_eq!(loaded_meta.id, "test_async");
assert!(loaded_messages.is_empty());
async_store.delete("test_async").await.unwrap();
let sessions = async_store.list().await.unwrap();
assert!(sessions.is_empty());
}
#[tokio::test]
async fn blocking_adapter_bridges_state_methods() {
let dir = tempfile::tempdir().unwrap();
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let async_store = BlockingSessionStore::new(jsonl_store);
let now = now_utc();
let meta = SessionMeta {
id: "state_async".to_string(),
title: "State test".to_string(),
created_at: now,
updated_at: now,
version: 1,
sequence: 0,
};
async_store.save("state_async", &meta, &[]).await.unwrap();
async_store
.save_state("state_async", &serde_json::json!({"scroll": 42}))
.await
.unwrap();
let state = async_store.load_state("state_async").await.unwrap();
assert_eq!(state, Some(serde_json::json!({"scroll": 42})));
}
#[tokio::test]
async fn blocking_adapter_bridges_save_full() {
let dir = tempfile::tempdir().unwrap();
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let async_store = BlockingSessionStore::new(jsonl_store);
let meta = test_meta("full_async");
let persisted_meta = async_store
.save_full(
"full_async",
&meta,
&[AgentMessage::Llm(swink_agent::LlmMessage::User(
swink_agent::UserMessage {
content: vec![swink_agent::ContentBlock::Text {
text: "hello".to_string(),
}],
timestamp: 1,
cache_hint: None,
},
))],
&serde_json::json!({"scroll": 7}),
)
.await
.unwrap();
assert_eq!(persisted_meta.sequence, 1);
let (_, loaded_messages) = async_store.load("full_async").await.unwrap();
assert_eq!(loaded_messages.len(), 1);
let state = async_store.load_state("full_async").await.unwrap();
assert_eq!(state, Some(serde_json::json!({"scroll": 7})));
}
#[tokio::test]
async fn blocking_adapter_bridges_load_full() {
let dir = tempfile::tempdir().unwrap();
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let async_store = BlockingSessionStore::new(jsonl_store);
async_store
.save_full(
"full_async_load",
&test_meta("full_async_load"),
&[AgentMessage::Llm(swink_agent::LlmMessage::User(
swink_agent::UserMessage {
content: vec![swink_agent::ContentBlock::Text {
text: "hello".to_string(),
}],
timestamp: 1,
cache_hint: None,
},
))],
&serde_json::json!({"scroll": 11}),
)
.await
.unwrap();
let (meta, messages, state) = async_store.load_full("full_async_load").await.unwrap();
assert_eq!(meta.id, "full_async_load");
assert_eq!(messages.len(), 1);
assert_eq!(state, Some(serde_json::json!({"scroll": 11})));
}
#[derive(Debug)]
struct TestCustomMsg {
data: String,
}
impl swink_agent::CustomMessage for TestCustomMsg {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn type_name(&self) -> Option<&str> {
Some("TestCustomMsg")
}
fn to_json(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({ "data": self.data }))
}
}
fn test_registry() -> CustomMessageRegistry {
let mut registry = CustomMessageRegistry::new();
registry.register(
"TestCustomMsg",
Box::new(|val: serde_json::Value| {
let data = val
.get("data")
.and_then(|v| v.as_str())
.ok_or_else(|| "missing data".to_string())?;
Ok(Box::new(TestCustomMsg {
data: data.to_string(),
}) as Box<dyn swink_agent::CustomMessage>)
}),
);
registry
}
fn test_meta(id: &str) -> SessionMeta {
let now = now_utc();
SessionMeta {
id: id.to_string(),
title: "Test".to_string(),
created_at: now,
updated_at: now,
version: 1,
sequence: 0,
}
}
#[tokio::test]
async fn blocking_adapter_preserves_custom_messages() {
let dir = tempfile::tempdir().unwrap();
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let registry = Arc::new(test_registry());
let async_store =
BlockingSessionStore::new(jsonl_store).with_registry(Arc::clone(®istry));
let messages: Vec<AgentMessage> = vec![
AgentMessage::Llm(swink_agent::LlmMessage::User(swink_agent::UserMessage {
content: vec![swink_agent::ContentBlock::Text {
text: "hello".to_string(),
}],
timestamp: 1,
cache_hint: None,
})),
AgentMessage::Custom(Box::new(TestCustomMsg {
data: "preserved".to_string(),
})),
AgentMessage::Llm(swink_agent::LlmMessage::User(swink_agent::UserMessage {
content: vec![swink_agent::ContentBlock::Text {
text: "world".to_string(),
}],
timestamp: 2,
cache_hint: None,
})),
];
let meta = test_meta("custom_save");
async_store
.save("custom_save", &meta, &messages)
.await
.unwrap();
let (_, loaded) = async_store.load("custom_save").await.unwrap();
assert_eq!(loaded.len(), 3, "all three messages must be loaded");
assert!(matches!(loaded[0], AgentMessage::Llm(_)));
assert!(matches!(loaded[1], AgentMessage::Custom(_)));
assert!(matches!(loaded[2], AgentMessage::Llm(_)));
let custom = loaded[1].downcast_ref::<TestCustomMsg>().unwrap();
assert_eq!(custom.data, "preserved");
}
#[tokio::test]
async fn blocking_adapter_passes_registry_on_load() {
let dir = tempfile::tempdir().unwrap();
let registry = Arc::new(test_registry());
{
let sync_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let meta = test_meta("reg_load");
let messages: Vec<AgentMessage> = vec![AgentMessage::Custom(Box::new(TestCustomMsg {
data: "via-registry".to_string(),
}))];
crate::store::SessionStore::save(&sync_store, "reg_load", &meta, &messages).unwrap();
}
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let async_store = BlockingSessionStore::new(jsonl_store).with_registry(registry);
let (_, loaded) = async_store.load("reg_load").await.unwrap();
assert_eq!(loaded.len(), 1);
assert!(matches!(loaded[0], AgentMessage::Custom(_)));
let custom = loaded[0].downcast_ref::<TestCustomMsg>().unwrap();
assert_eq!(custom.data, "via-registry");
}
#[tokio::test]
async fn blocking_adapter_without_registry_drops_custom_messages_on_load() {
let dir = tempfile::tempdir().unwrap();
{
let sync_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let meta = test_meta("drop_custom");
let messages: Vec<AgentMessage> = vec![
AgentMessage::Llm(swink_agent::LlmMessage::User(swink_agent::UserMessage {
content: vec![swink_agent::ContentBlock::Text {
text: "before".to_string(),
}],
timestamp: 1,
cache_hint: None,
})),
AgentMessage::Custom(Box::new(TestCustomMsg {
data: "not-restored".to_string(),
})),
AgentMessage::Llm(swink_agent::LlmMessage::User(swink_agent::UserMessage {
content: vec![swink_agent::ContentBlock::Text {
text: "after".to_string(),
}],
timestamp: 2,
cache_hint: None,
})),
];
crate::store::SessionStore::save(&sync_store, "drop_custom", &meta, &messages).unwrap();
}
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let async_store = BlockingSessionStore::new(jsonl_store);
let (_, loaded) = async_store.load("drop_custom").await.unwrap();
assert_eq!(
loaded.len(),
2,
"custom messages should be skipped without a registry"
);
assert!(matches!(loaded[0], AgentMessage::Llm(_)));
assert!(matches!(loaded[1], AgentMessage::Llm(_)));
}
#[tokio::test]
async fn blocking_adapter_append_preserves_custom_messages() {
let dir = tempfile::tempdir().unwrap();
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let registry = Arc::new(test_registry());
let async_store =
BlockingSessionStore::new(jsonl_store).with_registry(Arc::clone(®istry));
let meta = test_meta("custom_append");
let initial: Vec<AgentMessage> = vec![AgentMessage::Llm(swink_agent::LlmMessage::User(
swink_agent::UserMessage {
content: vec![swink_agent::ContentBlock::Text {
text: "start".to_string(),
}],
timestamp: 1,
cache_hint: None,
},
))];
async_store
.save("custom_append", &meta, &initial)
.await
.unwrap();
let appended: Vec<AgentMessage> = vec![AgentMessage::Custom(Box::new(TestCustomMsg {
data: "appended".to_string(),
}))];
async_store
.append("custom_append", &appended)
.await
.unwrap();
let (_, loaded) = async_store.load("custom_append").await.unwrap();
assert_eq!(loaded.len(), 2);
assert!(matches!(loaded[0], AgentMessage::Llm(_)));
assert!(matches!(loaded[1], AgentMessage::Custom(_)));
let custom = loaded[1].downcast_ref::<TestCustomMsg>().unwrap();
assert_eq!(custom.data, "appended");
}
#[tokio::test]
async fn arc_blocking_store_usable_concurrently() {
let dir = tempfile::tempdir().unwrap();
let jsonl_store = JsonlSessionStore::new(dir.path().to_path_buf()).unwrap();
let store = Arc::new(BlockingSessionStore::new(jsonl_store));
let mut handles = Vec::new();
for i in 0..3u8 {
let store = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let id = format!("concurrent_{i}");
let now = now_utc();
let meta = SessionMeta {
id: id.clone(),
title: format!("Session {i}"),
created_at: now,
updated_at: now,
version: 1,
sequence: 0,
};
store.save(&id, &meta, &[]).await.unwrap();
let (loaded, _) = store.load(&id).await.unwrap();
assert_eq!(loaded.id, id);
}));
}
for h in handles {
h.await.unwrap();
}
}
}