use async_trait::async_trait;
use crate::domain::{A2AError, ContextId, ContextState, Remembered, StateKey, StateScope};
#[async_trait]
pub trait AsyncContextStateStore: Send + Sync {
async fn load_state(
&self,
context_id: &ContextId,
caller: Option<&str>,
) -> Result<ContextState, A2AError>;
async fn remember(
&self,
context_id: &ContextId,
caller: Option<&str>,
key: &StateKey,
value: &str,
) -> Result<Remembered, A2AError>;
async fn forget(
&self,
context_id: &ContextId,
caller: Option<&str>,
key: &StateKey,
) -> Result<bool, A2AError>;
}
pub fn user_scope_needs_a_principal(key: &StateKey) -> A2AError {
A2AError::InvalidParams(format!(
"'{key}' is scoped to the user, and this agent authenticates nobody — configure \
`[server.auth]`, or drop the `user:` prefix to keep it against this conversation"
))
}
pub fn scope_key<'a>(
scope: StateScope,
context_id: &'a str,
caller: Option<&'a str>,
key: &StateKey,
) -> Result<Option<&'a str>, A2AError> {
match scope {
StateScope::Context => Ok(Some(context_id)),
StateScope::User => match caller {
Some(caller) => Ok(Some(caller)),
None => Err(user_scope_needs_a_principal(key)),
},
StateScope::Temp => Ok(None),
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoContextState;
#[async_trait]
impl AsyncContextStateStore for NoContextState {
async fn load_state(
&self,
_context_id: &ContextId,
_caller: Option<&str>,
) -> Result<ContextState, A2AError> {
Ok(ContextState::new())
}
async fn remember(
&self,
_context_id: &ContextId,
_caller: Option<&str>,
_key: &StateKey,
_value: &str,
) -> Result<Remembered, A2AError> {
Ok(Remembered::NotStored)
}
async fn forget(
&self,
_context_id: &ContextId,
_caller: Option<&str>,
_key: &StateKey,
) -> Result<bool, A2AError> {
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn key(raw: &str) -> StateKey {
raw.parse().unwrap()
}
#[tokio::test]
async fn the_no_state_store_remembers_nothing() {
let store = NoContextState;
let context = ContextId::from_str("ctx-1").unwrap();
assert_eq!(
store
.remember(&context, None, &key("project"), "a2a-rs")
.await
.unwrap(),
Remembered::NotStored
);
assert!(store.load_state(&context, None).await.unwrap().is_empty());
assert!(!store.forget(&context, None, &key("project")).await.unwrap());
}
#[test]
fn a_context_key_is_filed_under_the_context_and_a_user_key_under_the_caller() {
assert_eq!(
scope_key(StateScope::Context, "ctx-1", Some("alice"), &key("project")).unwrap(),
Some("ctx-1")
);
assert_eq!(
scope_key(StateScope::User, "ctx-1", Some("alice"), &key("user:tone")).unwrap(),
Some("alice")
);
}
#[test]
fn a_temp_key_is_filed_nowhere() {
assert_eq!(
scope_key(StateScope::Temp, "ctx-1", Some("alice"), &key("temp:draft")).unwrap(),
None
);
}
#[test]
fn a_user_key_with_no_principal_is_refused() {
let err = scope_key(StateScope::User, "ctx-1", None, &key("user:tone")).unwrap_err();
assert!(matches!(err, A2AError::InvalidParams(_)));
assert!(err.to_string().contains("server.auth"));
}
}