1use std::collections::HashMap;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use tokio::sync::Mutex; use crate::adapter::business::push_notification::{
14 PushNotificationRegistry, PushNotificationSender,
15};
16
17#[cfg(feature = "http-client")]
18use crate::adapter::business::push_notification::HttpPushNotificationSender;
19#[cfg(not(feature = "http-client"))]
20use crate::adapter::business::push_notification::NoopPushNotificationSender;
21use crate::domain::{
22 A2AError, ContextId, ContextState, Conversation, Digest, Message, ReadRefresh, Remembered,
23 RetentionPolicy, Seq, SequencedMessage, StateKey, StateScope, Swept, Task, TaskId,
24 TaskPushNotificationConfig, TaskState, TaskStateExt, VersionedTask,
25};
26use crate::port::{
27 AsyncContextStateStore, AsyncConversationStore, AsyncNotificationManager, AsyncPushNotifier,
28 AsyncRetention, AsyncTaskLifecycle, AsyncTaskQuery, AsyncTaskVersioning,
29 context_state::scope_key,
30};
31
32type StateBuckets = HashMap<(StateScope, String), HashMap<String, String>>;
35
36pub struct InMemoryTaskStorage {
45 pub(crate) tasks: Arc<Mutex<HashMap<String, Task>>>,
47 pub(crate) versions: Arc<Mutex<HashMap<String, u64>>>,
53 pub(crate) conversations: Arc<Mutex<HashMap<String, Vec<SequencedMessage>>>>,
66 pub(crate) digests: Arc<Mutex<HashMap<String, Vec<Digest>>>>,
70 pub(crate) context_owners: Arc<Mutex<HashMap<String, Option<String>>>>,
73 pub(crate) context_state: Arc<Mutex<StateBuckets>>,
78 pub(crate) context_touched: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
87 pub(crate) principal_touched: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
94 pub(crate) read_refresh: ReadRefresh,
100 pub(crate) next_seq: Arc<AtomicU64>,
103 pub(crate) push_notification_registry: Arc<PushNotificationRegistry>,
105}
106
107impl InMemoryTaskStorage {
108 pub fn new() -> Self {
110 #[cfg(feature = "http-client")]
112 let push_sender = HttpPushNotificationSender::new();
113 #[cfg(not(feature = "http-client"))]
114 let push_sender = NoopPushNotificationSender;
115
116 let push_registry = PushNotificationRegistry::new(push_sender);
117
118 Self {
119 tasks: Arc::new(Mutex::new(HashMap::new())),
120 versions: Arc::new(Mutex::new(HashMap::new())),
121 conversations: Arc::new(Mutex::new(HashMap::new())),
122 digests: Arc::new(Mutex::new(HashMap::new())),
123 context_owners: Arc::new(Mutex::new(HashMap::new())),
124 context_state: Arc::new(Mutex::new(HashMap::new())),
125 context_touched: Arc::new(Mutex::new(HashMap::new())),
126 principal_touched: Arc::new(Mutex::new(HashMap::new())),
127 read_refresh: ReadRefresh::never(),
128 next_seq: Arc::new(AtomicU64::new(1)),
129 push_notification_registry: Arc::new(push_registry),
130 }
131 }
132
133 pub fn with_push_sender(push_sender: impl PushNotificationSender + 'static) -> Self {
135 let push_registry = PushNotificationRegistry::new(push_sender);
136
137 Self {
138 tasks: Arc::new(Mutex::new(HashMap::new())),
139 versions: Arc::new(Mutex::new(HashMap::new())),
140 conversations: Arc::new(Mutex::new(HashMap::new())),
141 digests: Arc::new(Mutex::new(HashMap::new())),
142 context_owners: Arc::new(Mutex::new(HashMap::new())),
143 context_state: Arc::new(Mutex::new(HashMap::new())),
144 context_touched: Arc::new(Mutex::new(HashMap::new())),
145 principal_touched: Arc::new(Mutex::new(HashMap::new())),
146 read_refresh: ReadRefresh::never(),
147 next_seq: Arc::new(AtomicU64::new(1)),
148 push_notification_registry: Arc::new(push_registry),
149 }
150 }
151
152 #[must_use]
159 pub fn with_read_refresh(mut self, read_refresh: ReadRefresh) -> Self {
160 self.read_refresh = read_refresh;
161 self
162 }
163
164 async fn bump_version(&self, task_id: &str) -> u64 {
168 let mut versions = self.versions.lock().await;
169 let v = versions.entry(task_id.to_string()).or_insert(0);
170 *v += 1;
171 *v
172 }
173
174 pub fn push_notifier(&self) -> Arc<dyn AsyncPushNotifier> {
181 self.push_notification_registry.clone()
182 }
183}
184
185impl Default for InMemoryTaskStorage {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191impl InMemoryTaskStorage {
192 async fn append_to_conversation(&self, context_id: &str, message: Message) {
197 let seq = Seq::new(self.next_seq.fetch_add(1, Ordering::Relaxed));
198 let mut conversations = self.conversations.lock().await;
199 conversations
200 .entry(context_id.to_string())
201 .or_default()
202 .push(SequencedMessage { seq, message });
203 }
204
205 async fn touch_context(&self, context_id: &str) {
211 let now = Utc::now();
212 let mut touched = self.context_touched.lock().await;
213 touched
216 .entry(context_id.to_string())
217 .and_modify(|at| *at = (*at).max(now))
218 .or_insert(now);
219 }
220
221 async fn touch_principal(&self, principal: &str) {
223 let now = Utc::now();
224 let mut touched = self.principal_touched.lock().await;
225 touched
226 .entry(principal.to_string())
227 .and_modify(|at| *at = (*at).max(now))
228 .or_insert(now);
229 }
230
231 async fn refresh_principal(&self, principal: &str) {
243 if self.read_refresh.after_window().is_none() {
244 return;
245 }
246 let now = Utc::now();
247 let mut touched = self.principal_touched.lock().await;
248 if let Some(at) = touched.get_mut(principal)
249 && self.read_refresh.due(*at, now)
250 {
251 *at = now;
252 }
253 }
254
255 async fn claim_or_check_context(
261 &self,
262 context_id: &str,
263 caller: Option<&str>,
264 ) -> Result<(), A2AError> {
265 let mut owners = self.context_owners.lock().await;
266 match owners.get(context_id) {
267 Some(None) => Ok(()),
270 Some(Some(owner)) if Some(owner.as_str()) == caller => Ok(()),
271 Some(Some(_)) => Err(A2AError::ContextAccessDenied {
272 context_id: context_id.to_string(),
273 }),
274 None => {
275 owners.insert(context_id.to_string(), caller.map(str::to_string));
276 drop(owners);
277 self.touch_context(context_id).await;
281 Ok(())
282 }
283 }
284 }
285}
286
287#[async_trait]
288impl AsyncConversationStore for InMemoryTaskStorage {
289 async fn load(
290 &self,
291 context_id: &ContextId,
292 caller: Option<&str>,
293 limit: Option<u32>,
294 ) -> Result<Conversation, A2AError> {
295 let context_id = context_id.as_str();
296 self.claim_or_check_context(context_id, caller).await?;
301
302 let digest = {
305 let digests = self.digests.lock().await;
306 digests.get(context_id).and_then(|digests| {
307 digests
308 .iter()
309 .max_by_key(|digest| digest.covers_through)
310 .cloned()
311 })
312 };
313
314 let watermark = digest
315 .as_ref()
316 .map(|digest| digest.covers_through)
317 .unwrap_or(Seq::START);
318
319 let conversations = self.conversations.lock().await;
320 let mut tail: Vec<SequencedMessage> = conversations
321 .get(context_id)
322 .map(|log| {
323 log.iter()
324 .filter(|entry| entry.seq > watermark)
325 .cloned()
326 .collect()
327 })
328 .unwrap_or_default();
329
330 if let Some(limit) = limit {
334 let limit = limit as usize;
335 if tail.len() > limit {
336 tail.drain(..tail.len() - limit);
337 }
338 }
339
340 Ok(Conversation { digest, tail })
341 }
342
343 async fn compact(
344 &self,
345 context_id: &ContextId,
346 caller: Option<&str>,
347 digest: Digest,
348 ) -> Result<(), A2AError> {
349 let context_id = context_id.as_str();
350 self.claim_or_check_context(context_id, caller).await?;
351
352 {
353 let mut digests = self.digests.lock().await;
354 digests
355 .entry(context_id.to_string())
356 .or_default()
357 .push(digest);
358 }
359 self.touch_context(context_id).await;
360 Ok(())
361 }
362}
363
364#[async_trait]
365impl AsyncContextStateStore for InMemoryTaskStorage {
366 async fn load_state(
367 &self,
368 context_id: &ContextId,
369 caller: Option<&str>,
370 ) -> Result<ContextState, A2AError> {
371 let context_id = context_id.as_str();
372 self.claim_or_check_context(context_id, caller).await?;
375
376 let state = self.context_state.lock().await;
377 let mut loaded = ContextState::new();
378 let buckets = [
382 Some((StateScope::Context, context_id)),
383 caller.map(|caller| (StateScope::User, caller)),
384 ];
385 for (scope, scope_key) in buckets.into_iter().flatten() {
386 let Some(bucket) = state.get(&(scope, scope_key.to_string())) else {
387 continue;
388 };
389 for (name, value) in bucket {
390 match StateKey::scoped(scope, name) {
391 Ok(key) => loaded.insert(key, value.clone()),
392 Err(_e) => {
393 #[cfg(feature = "tracing")]
394 tracing::warn!("ignoring unusable state key '{name}': {_e}");
395 }
396 }
397 }
398 }
399 drop(state);
400
401 if let Some(caller) = caller {
402 self.refresh_principal(caller).await;
403 }
404 Ok(loaded)
405 }
406
407 async fn remember(
408 &self,
409 context_id: &ContextId,
410 caller: Option<&str>,
411 key: &StateKey,
412 value: &str,
413 ) -> Result<Remembered, A2AError> {
414 let context_id = context_id.as_str();
415 self.claim_or_check_context(context_id, caller).await?;
416
417 let Some(scope_key) = scope_key(key.scope(), context_id, caller, key)? else {
419 return Ok(Remembered::NotStored);
420 };
421
422 let previous = {
423 let mut state = self.context_state.lock().await;
424 state
425 .entry((key.scope(), scope_key.to_string()))
426 .or_default()
427 .insert(key.name().to_string(), value.to_string())
428 };
429
430 match key.scope() {
435 StateScope::User => self.touch_principal(scope_key).await,
436 _ => self.touch_context(context_id).await,
437 }
438
439 Ok(match previous {
440 None => Remembered::Stored,
441 Some(previous) if previous == value => Remembered::Unchanged,
442 Some(previous) => Remembered::Replaced { previous },
443 })
444 }
445
446 async fn forget(
447 &self,
448 context_id: &ContextId,
449 caller: Option<&str>,
450 key: &StateKey,
451 ) -> Result<bool, A2AError> {
452 let context_id = context_id.as_str();
453 self.claim_or_check_context(context_id, caller).await?;
454
455 let Some(scope_key) = scope_key(key.scope(), context_id, caller, key)? else {
456 return Ok(false);
457 };
458
459 let mut state = self.context_state.lock().await;
460 Ok(state
461 .get_mut(&(key.scope(), scope_key.to_string()))
462 .is_some_and(|bucket| bucket.remove(key.name()).is_some()))
463 }
464}
465
466#[async_trait]
467impl AsyncTaskLifecycle for InMemoryTaskStorage {
468 async fn create(&self, id: &TaskId, context_id: &ContextId) -> Result<Task, A2AError> {
469 let task_id = id.as_str();
470 let context_id = context_id.as_str();
471 let mut tasks_guard = self.tasks.lock().await;
472
473 if tasks_guard.contains_key(task_id) {
474 return Err(A2AError::TaskNotFound(format!(
475 "Task {} already exists",
476 task_id
477 )));
478 }
479
480 let task = Task::new(task_id.to_string(), context_id.to_string());
481 tasks_guard.insert(task_id.to_string(), task.clone());
482 self.bump_version(task_id).await; drop(tasks_guard);
484 self.touch_context(context_id).await;
485
486 Ok(task)
487 }
488
489 async fn update_status(
490 &self,
491 id: &TaskId,
492 state: TaskState,
493 message: Option<Message>,
494 ) -> Result<Task, A2AError> {
495 let task_id = id.as_str();
496 let mut tasks_guard = self.tasks.lock().await;
497
498 let task = tasks_guard
499 .get_mut(task_id)
500 .ok_or_else(|| A2AError::TaskNotFound(task_id.to_string()))?;
501
502 let context_id = task.context_id.clone();
503 let logged = message.clone();
504
505 task.update_status(state, message);
507 let updated = task.clone();
508 self.bump_version(task_id).await;
509
510 if let Some(message) = logged {
514 self.append_to_conversation(&context_id, message).await;
515 }
516 drop(tasks_guard);
517 self.touch_context(&context_id).await;
518
519 Ok(updated)
523 }
524
525 async fn exists(&self, id: &TaskId) -> Result<bool, A2AError> {
526 let task_id = id.as_str();
527 let tasks_guard = self.tasks.lock().await;
528 Ok(tasks_guard.contains_key(task_id))
529 }
530
531 async fn get(&self, id: &TaskId, history_length: Option<u32>) -> Result<Task, A2AError> {
532 let task_id = id.as_str();
533 let task = {
535 let tasks_guard = self.tasks.lock().await;
536
537 let Some(task) = tasks_guard.get(task_id) else {
538 return Err(A2AError::TaskNotFound(task_id.to_string()));
539 };
540
541 task.with_limited_history(history_length)
543 }; Ok(task)
546 }
547
548 async fn cancel(&self, id: &TaskId) -> Result<Task, A2AError> {
549 let task_id = id.as_str();
550 let mut tasks_guard = self.tasks.lock().await;
551
552 let Some(task) = tasks_guard.get(task_id) else {
553 return Err(A2AError::TaskNotFound(task_id.to_string()));
554 };
555
556 let mut updated_task = task.clone();
557
558 if !updated_task.status.state.is_cancelable() {
563 return Err(A2AError::TaskNotCancelable(format!(
564 "Task {} has already finished in state {:?} and cannot be canceled",
565 task_id, updated_task.status.state
566 )));
567 }
568
569 let cancel_message = Message {
571 role: ::buffa::EnumValue::from(crate::domain::Role::Agent),
572 parts: vec![crate::domain::Part::text(format!(
573 "Task {} canceled.",
574 task_id
575 ))],
576 message_id: uuid::Uuid::new_v4().to_string(),
577 task_id: task_id.to_string(),
578 context_id: updated_task.context_id.clone(),
579 ..Default::default()
580 };
581
582 updated_task.update_status(TaskState::Canceled, Some(cancel_message));
584 let context_id = updated_task.context_id.clone();
585 tasks_guard.insert(task_id.to_string(), updated_task.clone());
586 self.bump_version(task_id).await;
587 drop(tasks_guard);
588 self.touch_context(&context_id).await;
589
590 Ok(updated_task)
593 }
594}
595
596#[async_trait]
597impl AsyncTaskVersioning for InMemoryTaskStorage {
598 async fn version(&self, id: &TaskId) -> Result<u64, A2AError> {
599 let task_id = id.as_str();
600 let tasks_guard = self.tasks.lock().await;
601 if !tasks_guard.contains_key(task_id) {
602 return Err(A2AError::TaskNotFound(task_id.to_string()));
603 }
604 let versions = self.versions.lock().await;
605 Ok(versions.get(task_id).copied().unwrap_or(0))
606 }
607
608 async fn get_versioned(
609 &self,
610 id: &TaskId,
611 history_length: Option<u32>,
612 ) -> Result<VersionedTask, A2AError> {
613 let task_id = id.as_str();
614 let tasks_guard = self.tasks.lock().await;
615 let Some(task) = tasks_guard.get(task_id) else {
616 return Err(A2AError::TaskNotFound(task_id.to_string()));
617 };
618 let task = task.with_limited_history(history_length);
619 let versions = self.versions.lock().await;
620 let version = versions.get(task_id).copied().unwrap_or(0);
621 Ok(VersionedTask::new(task, version))
622 }
623
624 async fn update_status_checked(
625 &self,
626 id: &TaskId,
627 expected: u64,
628 state: TaskState,
629 message: Option<Message>,
630 ) -> Result<VersionedTask, A2AError> {
631 let task_id = id.as_str();
632 let mut tasks_guard = self.tasks.lock().await;
635 let task = tasks_guard
636 .get_mut(task_id)
637 .ok_or_else(|| A2AError::TaskNotFound(task_id.to_string()))?;
638 let mut versions = self.versions.lock().await;
639 let current = versions.get(task_id).copied().unwrap_or(0);
640 if current != expected {
641 return Err(A2AError::VersionConflict {
642 id: task_id.to_string(),
643 expected,
644 actual: current,
645 });
646 }
647 task.update_status(state, message);
648 let new_version = current + 1;
649 versions.insert(task_id.to_string(), new_version);
650 Ok(VersionedTask::new(task.clone(), new_version))
651 }
652}
653
654#[async_trait]
655impl AsyncTaskQuery for InMemoryTaskStorage {
656 async fn list(
657 &self,
658 params: &crate::domain::ListTasksParams,
659 ) -> Result<crate::domain::ListTasksResult, A2AError> {
660 use crate::domain::ListTasksResult;
661
662 let tasks_guard = self.tasks.lock().await;
663
664 let mut filtered_tasks: Vec<_> = tasks_guard
666 .values()
667 .filter(|task| {
668 if let Some(ref context_id) = params.context_id
670 && &task.context_id != context_id
671 {
672 return false;
673 }
674
675 if let Some(ref status) = params.status
677 && &task.status.state != status
678 {
679 return false;
680 }
681
682 if let Some(status_timestamp_after) = ¶ms.status_timestamp_after
684 && let Ok(after_dt) =
685 chrono::DateTime::parse_from_rfc3339(status_timestamp_after)
686 && let Some(timestamp) = task.status.timestamp_utc()
687 && timestamp <= after_dt.with_timezone(&chrono::Utc)
688 {
689 return false;
690 }
691
692 true
693 })
694 .cloned()
695 .collect();
696
697 filtered_tasks.sort_by(|a, b| {
699 let a_time = a
700 .status
701 .timestamp_utc()
702 .map(|t| t.timestamp_millis())
703 .unwrap_or(0);
704 let b_time = b
705 .status
706 .timestamp_utc()
707 .map(|t| t.timestamp_millis())
708 .unwrap_or(0);
709 b_time.cmp(&a_time)
710 });
711
712 let total_size = filtered_tasks.len() as i32;
713
714 let page_size = params.page_size.unwrap_or(50).clamp(1, 100) as usize;
716 let page_start = if let Some(ref token) = params.page_token {
717 token.parse::<usize>().unwrap_or(0)
719 } else {
720 0
721 };
722
723 let page_end = (page_start + page_size).min(filtered_tasks.len());
724 let has_more = page_end < filtered_tasks.len();
725
726 let mut page_tasks: Vec<_> = filtered_tasks[page_start..page_end].to_vec();
728
729 let history_length = params.history_length.unwrap_or(0);
731 for task in &mut page_tasks {
732 *task = task.with_limited_history(Some(history_length as u32));
733
734 if !params.include_artifacts.unwrap_or(false) {
736 task.artifacts.clear();
737 }
738 }
739
740 let next_page_token = if has_more {
742 page_end.to_string()
743 } else {
744 String::new()
745 };
746
747 Ok(ListTasksResult {
748 tasks: page_tasks,
749 total_size,
750 page_size: page_size as i32,
751 next_page_token,
752 })
753 }
754}
755
756#[async_trait]
761impl AsyncNotificationManager for InMemoryTaskStorage {
762 async fn set_config(
763 &self,
764 config: &TaskPushNotificationConfig,
765 ) -> Result<TaskPushNotificationConfig, A2AError> {
766 #[cfg(feature = "tracing")]
767 tracing::info!(
768 task_id = %config.task_id,
769 url = %config.url,
770 "🚀 Registering push notification config for task"
771 );
772
773 self.push_notification_registry
775 .register(&config.task_id, config.clone())
776 .await?;
777
778 #[cfg(feature = "tracing")]
779 tracing::info!(
780 task_id = %config.task_id,
781 "✅ Push notification config registered successfully"
782 );
783
784 Ok(config.clone())
785 }
786
787 async fn get_config(
788 &self,
789 params: &crate::domain::GetTaskPushNotificationConfigParams,
790 ) -> Result<TaskPushNotificationConfig, A2AError> {
791 match self
792 .push_notification_registry
793 .get_config(¶ms.id)
794 .await?
795 {
796 Some(config) => Ok(config),
797 None => Err(A2AError::PushNotificationNotSupported),
798 }
799 }
800
801 async fn list_configs(
802 &self,
803 params: &crate::domain::ListTaskPushNotificationConfigsParams,
804 ) -> Result<Vec<TaskPushNotificationConfig>, A2AError> {
805 match self
808 .push_notification_registry
809 .get_config(¶ms.id)
810 .await?
811 {
812 Some(config) => Ok(vec![config]),
813 None => Ok(vec![]),
814 }
815 }
816
817 async fn delete_config(
818 &self,
819 params: &crate::domain::DeleteTaskPushNotificationConfigParams,
820 ) -> Result<(), A2AError> {
821 self.push_notification_registry
824 .unregister(¶ms.id)
825 .await?;
826 Ok(())
827 }
828}
829
830#[async_trait]
831impl AsyncRetention for InMemoryTaskStorage {
832 async fn sweep(&self, policy: &RetentionPolicy, now: DateTime<Utc>) -> Result<Swept, A2AError> {
841 let mut swept = Swept::default();
842
843 if let Some(cutoff) = policy.context_cutoff(now) {
844 let mut tasks = self.tasks.lock().await;
845 let mut versions = self.versions.lock().await;
846 let mut conversations = self.conversations.lock().await;
847 let mut digests = self.digests.lock().await;
848 let mut owners = self.context_owners.lock().await;
849 let mut state = self.context_state.lock().await;
850 let mut touched = self.context_touched.lock().await;
851
852 let idle: Vec<String> = touched
856 .iter()
857 .filter(|(_, at)| **at < cutoff)
858 .map(|(context_id, _)| context_id.clone())
859 .collect();
860
861 let mut by_context: HashMap<&str, Vec<&Task>> = HashMap::new();
864 for task in tasks.values() {
865 by_context
866 .entry(task.context_id.as_str())
867 .or_default()
868 .push(task);
869 }
870
871 let mut sweepable = Vec::new();
872 for context_id in &idle {
873 let held = by_context.get(context_id.as_str());
874
875 let running = held
882 .is_some_and(|tasks| tasks.iter().any(|task| !task.status.state.is_settled()));
883 if running {
884 continue;
885 }
886
887 let doomed: Vec<String> = held
888 .map(|tasks| tasks.iter().map(|task| task.id.clone()).collect())
889 .unwrap_or_default();
890 sweepable.push((context_id.clone(), doomed));
891 }
892 drop(by_context);
894
895 for (context_id, doomed) in sweepable {
896 for task_id in &doomed {
897 tasks.remove(task_id);
898 versions.remove(task_id);
899 self.push_notification_registry.unregister(task_id).await?;
906 }
907 swept.tasks += doomed.len() as u64;
908
909 swept.messages += conversations
910 .remove(&context_id)
911 .map_or(0, |log| log.len() as u64);
912 swept.digests += digests
913 .remove(&context_id)
914 .map_or(0, |appended| appended.len() as u64);
915 swept.state_keys += state
916 .remove(&(StateScope::Context, context_id.clone()))
917 .map_or(0, |bucket| bucket.len() as u64);
918 owners.remove(&context_id);
919 touched.remove(&context_id);
920 swept.contexts += 1;
921 }
922 }
923
924 if let Some(cutoff) = policy.user_state_cutoff(now) {
925 let mut state = self.context_state.lock().await;
926 let mut touched = self.principal_touched.lock().await;
927
928 let expired: Vec<String> = touched
929 .iter()
930 .filter(|(_, at)| **at < cutoff)
931 .map(|(principal, _)| principal.clone())
932 .collect();
933
934 for principal in expired {
935 swept.state_keys += state
936 .remove(&(StateScope::User, principal.clone()))
937 .map_or(0, |bucket| bucket.len() as u64);
938 touched.remove(&principal);
939 }
940 }
941
942 Ok(swept)
943 }
944}
945
946impl Clone for InMemoryTaskStorage {
947 fn clone(&self) -> Self {
948 Self {
949 tasks: self.tasks.clone(),
950 versions: self.versions.clone(),
951 conversations: self.conversations.clone(),
952 digests: self.digests.clone(),
953 context_owners: self.context_owners.clone(),
954 context_state: self.context_state.clone(),
955 context_touched: self.context_touched.clone(),
956 principal_touched: self.principal_touched.clone(),
957 read_refresh: self.read_refresh,
958 next_seq: self.next_seq.clone(),
959 push_notification_registry: self.push_notification_registry.clone(),
960 }
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967 use crate::domain::ContextId;
968
969 fn tid(s: &str) -> TaskId {
970 s.parse().unwrap()
971 }
972 fn cid(s: &str) -> ContextId {
973 s.parse().unwrap()
974 }
975
976 async fn age_the_bag(store: &InMemoryTaskStorage, principal: &str, to: DateTime<Utc>) {
981 store
982 .principal_touched
983 .lock()
984 .await
985 .insert(principal.to_string(), to);
986 }
987
988 async fn bag_written_at(store: &InMemoryTaskStorage, principal: &str) -> DateTime<Utc> {
989 store.principal_touched.lock().await[principal]
990 }
991
992 #[tokio::test]
993 async fn a_read_refreshes_a_bag_that_is_old_enough() {
994 let day = std::time::Duration::from_secs(24 * 60 * 60);
995 let store = InMemoryTaskStorage::new().with_read_refresh(ReadRefresh::after(day));
996 let context = cid("ctx-refresh");
997
998 store
999 .remember(
1000 &context,
1001 Some("alice"),
1002 &StateKey::scoped(StateScope::User, "name").unwrap(),
1003 "Emil",
1004 )
1005 .await
1006 .unwrap();
1007
1008 let long_ago = Utc::now() - chrono::TimeDelta::days(7);
1009 age_the_bag(&store, "alice", long_ago).await;
1010
1011 store.load_state(&context, Some("alice")).await.unwrap();
1012
1013 assert!(
1014 bag_written_at(&store, "alice").await > long_ago,
1015 "a read of a week-old bag should have refreshed it"
1016 );
1017 }
1018
1019 #[tokio::test]
1023 async fn a_second_read_inside_the_window_writes_nothing() {
1024 let day = std::time::Duration::from_secs(24 * 60 * 60);
1025 let store = InMemoryTaskStorage::new().with_read_refresh(ReadRefresh::after(day));
1026 let context = cid("ctx-refresh-twice");
1027
1028 store
1029 .remember(
1030 &context,
1031 Some("alice"),
1032 &StateKey::scoped(StateScope::User, "name").unwrap(),
1033 "Emil",
1034 )
1035 .await
1036 .unwrap();
1037 age_the_bag(&store, "alice", Utc::now() - chrono::TimeDelta::days(7)).await;
1038
1039 store.load_state(&context, Some("alice")).await.unwrap();
1040 let after_first = bag_written_at(&store, "alice").await;
1041 store.load_state(&context, Some("alice")).await.unwrap();
1042
1043 assert_eq!(
1044 bag_written_at(&store, "alice").await,
1045 after_first,
1046 "the bag was already fresh, so the second read wrote nothing"
1047 );
1048 }
1049
1050 #[tokio::test]
1053 async fn without_a_refresh_a_read_writes_nothing() {
1054 let store = InMemoryTaskStorage::new();
1055 let context = cid("ctx-no-refresh");
1056
1057 store
1058 .remember(
1059 &context,
1060 Some("alice"),
1061 &StateKey::scoped(StateScope::User, "name").unwrap(),
1062 "Emil",
1063 )
1064 .await
1065 .unwrap();
1066 let long_ago = Utc::now() - chrono::TimeDelta::days(7);
1067 age_the_bag(&store, "alice", long_ago).await;
1068
1069 store.load_state(&context, Some("alice")).await.unwrap();
1070
1071 assert_eq!(bag_written_at(&store, "alice").await, long_ago);
1072 }
1073
1074 #[tokio::test]
1077 async fn a_refresh_does_not_invent_a_bag_that_was_never_written() {
1078 let store = InMemoryTaskStorage::new()
1079 .with_read_refresh(ReadRefresh::after(std::time::Duration::ZERO));
1080
1081 store
1082 .load_state(&cid("ctx-empty"), Some("alice"))
1083 .await
1084 .unwrap();
1085
1086 assert!(store.principal_touched.lock().await.is_empty());
1087 }
1088
1089 fn said(text: &str) -> Message {
1090 use crate::domain::{Part, Role};
1091 Message::builder()
1092 .role(Role::User)
1093 .parts(vec![Part::text(text.to_string())])
1094 .message_id(uuid::Uuid::new_v4().to_string())
1095 .build()
1096 }
1097
1098 fn texts(conversation: &Conversation) -> Vec<String> {
1099 use crate::domain::part;
1100 conversation
1101 .tail
1102 .iter()
1103 .flat_map(|entry| {
1104 entry.message.parts.iter().filter_map(|p| match &p.content {
1105 Some(part::Content::Text(text)) => Some(text.clone()),
1106 _ => None,
1107 })
1108 })
1109 .collect()
1110 }
1111
1112 #[tokio::test]
1116 async fn a_context_reads_back_as_one_ordered_conversation() {
1117 let store = InMemoryTaskStorage::new();
1118 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1119 store
1120 .update_status(&tid("t1"), TaskState::Working, Some(said("what is it")))
1121 .await
1122 .unwrap();
1123 store
1124 .update_status(&tid("t1"), TaskState::Completed, Some(said("Oslo")))
1125 .await
1126 .unwrap();
1127
1128 store.create(&tid("t2"), &cid("c1")).await.unwrap();
1129 store
1130 .update_status(
1131 &tid("t2"),
1132 TaskState::Completed,
1133 Some(said("and the population")),
1134 )
1135 .await
1136 .unwrap();
1137
1138 let conversation = store.load(&cid("c1"), None, None).await.unwrap();
1139 assert_eq!(
1140 texts(&conversation),
1141 vec!["what is it", "Oslo", "and the population"]
1142 );
1143 }
1144
1145 #[tokio::test]
1148 async fn a_transition_without_a_message_records_nothing() {
1149 let store = InMemoryTaskStorage::new();
1150 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1151 store
1152 .update_status(&tid("t1"), TaskState::Working, None)
1153 .await
1154 .unwrap();
1155
1156 assert!(store.load(&cid("c1"), None, None).await.unwrap().is_empty());
1157 }
1158
1159 #[tokio::test]
1162 async fn conversations_are_separate_per_context() {
1163 let store = InMemoryTaskStorage::new();
1164 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1165 store.create(&tid("t2"), &cid("c2")).await.unwrap();
1166 store
1167 .update_status(&tid("t1"), TaskState::Completed, Some(said("in one")))
1168 .await
1169 .unwrap();
1170 store
1171 .update_status(&tid("t2"), TaskState::Completed, Some(said("in two")))
1172 .await
1173 .unwrap();
1174
1175 let one = store.load(&cid("c1"), None, None).await.unwrap();
1176 assert_eq!(texts(&one), vec!["in one"]);
1177 }
1178
1179 #[tokio::test]
1183 async fn a_digest_replaces_the_messages_it_covers() {
1184 let store = InMemoryTaskStorage::new();
1185 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1186 for text in ["one", "two", "three"] {
1187 store
1188 .update_status(&tid("t1"), TaskState::Working, Some(said(text)))
1189 .await
1190 .unwrap();
1191 }
1192
1193 let before = store.load(&cid("c1"), None, None).await.unwrap();
1194 let watermark = before.tail[1].seq;
1195 store
1196 .compact(
1197 &cid("c1"),
1198 None,
1199 Digest {
1200 covers_through: watermark,
1201 summary: "they said one and two".to_string(),
1202 replaced_messages: 2,
1203 model: "test".to_string(),
1204 },
1205 )
1206 .await
1207 .unwrap();
1208
1209 let after = store.load(&cid("c1"), None, None).await.unwrap();
1210 assert_eq!(after.summary(), Some("they said one and two"));
1211 assert_eq!(texts(&after), vec!["three"]);
1212 }
1213
1214 #[tokio::test]
1218 async fn concurrent_compaction_keeps_the_widest_digest() {
1219 let store = InMemoryTaskStorage::new();
1220 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1221 for text in ["one", "two", "three"] {
1222 store
1223 .update_status(&tid("t1"), TaskState::Working, Some(said(text)))
1224 .await
1225 .unwrap();
1226 }
1227 let loaded = store.load(&cid("c1"), None, None).await.unwrap();
1228
1229 for (seq, summary) in [
1232 (loaded.tail[2].seq, "covers all three"),
1233 (loaded.tail[0].seq, "covers only the first"),
1234 ] {
1235 store
1236 .compact(
1237 &cid("c1"),
1238 None,
1239 Digest {
1240 covers_through: seq,
1241 summary: summary.to_string(),
1242 replaced_messages: 1,
1243 model: "test".to_string(),
1244 },
1245 )
1246 .await
1247 .unwrap();
1248 }
1249
1250 let after = store.load(&cid("c1"), None, None).await.unwrap();
1251 assert_eq!(after.summary(), Some("covers all three"));
1252 assert!(after.tail.is_empty(), "{:?}", texts(&after));
1253 }
1254
1255 #[tokio::test]
1258 async fn limiting_a_conversation_keeps_the_most_recent_messages() {
1259 let store = InMemoryTaskStorage::new();
1260 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1261 for text in ["one", "two", "three", "four"] {
1262 store
1263 .update_status(&tid("t1"), TaskState::Working, Some(said(text)))
1264 .await
1265 .unwrap();
1266 }
1267
1268 let conversation = store.load(&cid("c1"), None, Some(2)).await.unwrap();
1269 assert_eq!(texts(&conversation), vec!["three", "four"]);
1270 }
1271
1272 #[tokio::test]
1275 async fn a_context_belongs_to_whoever_started_it() {
1276 let store = InMemoryTaskStorage::new();
1277 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1278 store
1279 .update_status(&tid("t1"), TaskState::Completed, Some(said("private")))
1280 .await
1281 .unwrap();
1282
1283 store.load(&cid("c1"), Some("alice"), None).await.unwrap();
1285 assert_eq!(
1286 texts(&store.load(&cid("c1"), Some("alice"), None).await.unwrap()),
1287 vec!["private"]
1288 );
1289
1290 let err = store
1291 .load(&cid("c1"), Some("mallory"), None)
1292 .await
1293 .unwrap_err();
1294 assert!(
1295 matches!(err, A2AError::ContextAccessDenied { .. }),
1296 "{err:?}"
1297 );
1298
1299 let err = store
1301 .compact(
1302 &cid("c1"),
1303 Some("mallory"),
1304 Digest {
1305 covers_through: Seq::new(1),
1306 summary: "mine now".to_string(),
1307 replaced_messages: 1,
1308 model: "test".to_string(),
1309 },
1310 )
1311 .await
1312 .unwrap_err();
1313 assert!(matches!(err, A2AError::ContextAccessDenied { .. }));
1314 }
1315
1316 #[tokio::test]
1320 async fn an_unowned_context_stays_open() {
1321 let store = InMemoryTaskStorage::new();
1322 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1323 store
1324 .update_status(&tid("t1"), TaskState::Completed, Some(said("open")))
1325 .await
1326 .unwrap();
1327
1328 store.load(&cid("c1"), None, None).await.unwrap();
1329 assert_eq!(
1330 texts(&store.load(&cid("c1"), Some("anyone"), None).await.unwrap()),
1331 vec!["open"]
1332 );
1333 }
1334
1335 #[tokio::test]
1336 async fn an_unknown_context_is_empty_rather_than_an_error() {
1337 let store = InMemoryTaskStorage::new();
1338 assert!(
1339 store
1340 .load(&cid("never-seen"), None, None)
1341 .await
1342 .unwrap()
1343 .is_empty()
1344 );
1345 }
1346
1347 #[tokio::test]
1348 async fn versioning_tracks_and_guards_mutations() {
1349 let store = InMemoryTaskStorage::new();
1350 store.create(&tid("t1"), &cid("c1")).await.unwrap();
1351 assert_eq!(store.version(&tid("t1")).await.unwrap(), 1);
1352
1353 store
1355 .update_status(&tid("t1"), TaskState::Working, None)
1356 .await
1357 .unwrap();
1358 let snap = store.get_versioned(&tid("t1"), None).await.unwrap();
1359 assert_eq!(snap.version, 2);
1360
1361 let err = store
1363 .update_status_checked(&tid("t1"), 1, TaskState::Completed, None)
1364 .await
1365 .unwrap_err();
1366 assert!(matches!(
1367 err,
1368 A2AError::VersionConflict {
1369 expected: 1,
1370 actual: 2,
1371 ..
1372 }
1373 ));
1374 assert_eq!(
1375 store.get(&tid("t1"), None).await.unwrap().status.state,
1376 TaskState::Working
1377 );
1378
1379 let ok = store
1381 .update_status_checked(&tid("t1"), 2, TaskState::Completed, None)
1382 .await
1383 .unwrap();
1384 assert_eq!(ok.version, 3);
1385 assert_eq!(ok.task.status.state, TaskState::Completed);
1386 }
1387}