Skip to main content

a2a_rs/adapter/storage/
task_storage.rs

1//! In-memory task storage implementation
2
3// This module is already conditionally compiled with #[cfg(feature = "server")] in mod.rs
4
5use 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; // Changed from std::sync::Mutex
12
13use 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
32/// The state bag's buckets: a scope and what that scope files under, to the
33/// names and values kept there.
34type StateBuckets = HashMap<(StateScope, String), HashMap<String, String>>;
35
36/// Simple in-memory task storage for testing and example purposes.
37///
38/// Persistence-only: streaming fan-out lives in
39/// [`InMemoryStreamingHandler`](crate::adapter::InMemoryStreamingHandler) and
40/// push-webhook delivery behind the [`AsyncPushNotifier`] port (this struct hands
41/// out its registry via [`push_notifier`](Self::push_notifier)). The store still
42/// owns push-config CRUD ([`AsyncNotificationManager`]) because that is config
43/// *persistence*.
44pub struct InMemoryTaskStorage {
45    /// Tasks stored by ID
46    pub(crate) tasks: Arc<Mutex<HashMap<String, Task>>>,
47    /// Per-task optimistic-concurrency version, bumped on every mutation.
48    ///
49    /// A separate map keyed by the same task id. Mutators always lock `tasks`
50    /// first and `versions` second, so the two stay consistent and never
51    /// deadlock (see [`AsyncTaskVersioning`]).
52    pub(crate) versions: Arc<Mutex<HashMap<String, u64>>>,
53    /// The conversation log, keyed by context id.
54    ///
55    /// A separate append-only list rather than something derived from `tasks`,
56    /// mirroring what the SQL adapter keeps in `task_history`. Deriving it would
57    /// need a total order across tasks that `Task` does not carry, and the point
58    /// of having both adapters is that they model the same thing.
59    ///
60    /// The lock order is `tasks` → `versions` → `conversations` → `digests` →
61    /// `context_owners` → `context_state` → `context_touched` →
62    /// `principal_touched`. `update_status` takes the first three in that order
63    /// and [`sweep`](AsyncRetention::sweep) takes all of them; every other
64    /// caller takes one at a time.
65    pub(crate) conversations: Arc<Mutex<HashMap<String, Vec<SequencedMessage>>>>,
66    /// Appended digests, keyed by context id. Newest wins on load, by watermark
67    /// rather than by position, since two concurrent compactions can append out
68    /// of watermark order.
69    pub(crate) digests: Arc<Mutex<HashMap<String, Vec<Digest>>>>,
70    /// The principal that first wrote to each context. `None` is unowned and
71    /// stays readable by anyone.
72    pub(crate) context_owners: Arc<Mutex<HashMap<String, Option<String>>>>,
73    /// The state bag, in the two buckets it is partitioned into: keyed by scope
74    /// and by whatever that scope files under — a context id for
75    /// [`StateScope::Context`], a principal for [`StateScope::User`]. Held apart
76    /// from `conversations` because a `user:` bucket belongs to no context.
77    pub(crate) context_state: Arc<Mutex<StateBuckets>>,
78    /// When each context was last **written** to, which is what
79    /// [`RetentionPolicy`] measures idleness from.
80    ///
81    /// A separate map because nothing else here carries a wall-clock time a
82    /// sweep could read: `SequencedMessage` has a `Seq` and no timestamp, and
83    /// the state bag has values and no timestamps. It mirrors what the SQL
84    /// adapter gets for free from `updated_at` columns — including that reads
85    /// do not bump it, so both stores expire a read-only context alike.
86    pub(crate) context_touched: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
87    /// When each principal's `user:`-scoped state was last written.
88    ///
89    /// Apart from `context_touched` for the reason the scope exists: a `user:`
90    /// bucket outlives every context it was written from, so no context's
91    /// idleness says whether it is stale. The SQL adapter reads the same thing
92    /// as `MAX(updated_at)` over the principal's rows.
93    pub(crate) principal_touched: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
94    /// Whether a read of a principal's `user:` bag refreshes `principal_touched`.
95    ///
96    /// [`ReadRefresh::never`] by default, which is the behaviour every other
97    /// timestamp here has: reads record nothing. The SQL adapter carries the
98    /// same value and applies it to the same bucket.
99    pub(crate) read_refresh: ReadRefresh,
100    /// Hands out conversation sequence numbers. Shared across contexts, which is
101    /// harmless: `Seq` only has to be monotonic *within* one.
102    pub(crate) next_seq: Arc<AtomicU64>,
103    /// Push notification registry (config store + delivery backend)
104    pub(crate) push_notification_registry: Arc<PushNotificationRegistry>,
105}
106
107impl InMemoryTaskStorage {
108    /// Create a new empty task storage
109    pub fn new() -> Self {
110        // Use the appropriate push notification sender based on available features
111        #[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    /// Create a new task storage with a custom push notification sender
134    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    /// Let a read of a principal's `user:` bag count as keeping it alive.
153    ///
154    /// Off by default. See [`ReadRefresh`] for what it costs and why the window
155    /// is not a bool; pair it with the [`RetentionPolicy`] a sweep will run
156    /// under, which [`ReadRefresh::halfway_through`] does from the policy
157    /// itself.
158    #[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    /// Bump (or initialize) the stored version for a task, returning the new
165    /// value. Callers already hold the `tasks` lock; this acquires `versions`
166    /// second, preserving the global lock order.
167    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    /// Hand out this store's push-notification registry as an
175    /// [`AsyncPushNotifier`].
176    ///
177    /// The returned notifier shares the same config registry the store writes to
178    /// via [`AsyncNotificationManager::set_config`], so a config registered on
179    /// the store is immediately visible to the notifier at the composition edge.
180    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    /// Record `message` at the end of `context_id`'s conversation.
193    ///
194    /// Callers hold the `tasks` lock; this takes `conversations` after it,
195    /// preserving the order documented on the field.
196    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    /// Record that `context_id` was written to, for the retention sweep.
206    ///
207    /// Called from the mutators only. A read must not refresh idleness — the SQL
208    /// adapter's `updated_at` columns are not bumped by one either, and two
209    /// stores that disagree about what "idle" means are two retention policies.
210    async fn touch_context(&self, context_id: &str) {
211        let now = Utc::now();
212        let mut touched = self.context_touched.lock().await;
213        // `max`, not overwrite: two turns of one conversation can land out of
214        // order, and the newer write is the one idleness is measured from.
215        touched
216            .entry(context_id.to_string())
217            .and_modify(|at| *at = (*at).max(now))
218            .or_insert(now);
219    }
220
221    /// Record that `principal` had a `user:`-scoped key written.
222    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    /// Let this read count as keeping `principal`'s `user:` bag alive, if the
232    /// store was configured to and the bag is old enough to need it.
233    ///
234    /// The one place a read writes a timestamp, and it does so under a rule the
235    /// domain owns rather than one this adapter invented — see [`ReadRefresh`].
236    /// Bumps only an entry that exists: a principal that never wrote a `user:`
237    /// key has no bag to keep alive, and inserting one here would invent a
238    /// write that never happened.
239    ///
240    /// Takes `principal_touched` last, which is the order documented on
241    /// `conversations`.
242    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    /// Claim `context_id` for `caller` if nobody holds it, then refuse a caller
256    /// that is not the holder.
257    ///
258    /// One method because claim and check race otherwise: two first-turn
259    /// requests would both see "unclaimed" and both write an owner.
260    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            // Unowned, either because nothing claimed it or because it was
268            // claimed with no principal. Both stay open.
269            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                // The claim itself is a write — it is what inserts the SQL
278                // adapter's `contexts` row — so a context opened and then only
279                // read is idle from the moment it was opened, not never.
280                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        // Claims on read, not only on write. A handler loads history at the top
297        // of every turn, so the first turn of a conversation is what establishes
298        // who owns it; claiming only on compaction would leave a context
299        // readable by anyone until it first grew long enough to summarize.
300        self.claim_or_check_context(context_id, caller).await?;
301
302        // Highest watermark, not newest appended: two concurrent compactions can
303        // land out of order, and the one covering more is the one to use.
304        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        // Keep the newest when limiting: the older part is what a summary
331        // stands in for, and dropping the recent end would leave the model
332        // answering with the least relevant half of the conversation.
333        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        // Claimed on read for the same reason the conversation is: whoever holds
373        // a context id would otherwise read what was remembered in it.
374        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        // The context's own keys, then the caller's. A principal has none when
379        // the agent authenticates nobody, and nothing could have been written
380        // under one either.
381        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        // `None` is `temp:`, which is stored nowhere.
418        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        // Which clock this write advances follows the scope, not the context it
431        // was written from: a `user:` key outlives every context that touches it.
432        // Advanced even when the value did not move: a write reached the store,
433        // and idleness measures writes.
434        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; // version 0 -> 1
483        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        // Update the task status with the optional message
506        task.update_status(state, message);
507        let updated = task.clone();
508        self.bump_version(task_id).await;
509
510        // The same message goes onto the context's conversation log, which is
511        // what a later turn reads back as history. Only messages: a status
512        // transition carrying none has nothing to record.
513        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        // Persistence only: announcing the change to streaming subscribers is
520        // the orchestration layer's job (see `TaskStatusBroadcast`), not a side
521        // effect of the mutator.
522        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        // Get the task
534        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            // Apply history length limitation if specified
542            task.with_limited_history(history_length)
543        }; // Lock is dropped here
544
545        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        // Anything that has not finished can be canceled — a queued
559        // (`Submitted`) task most of all, and an `InputRequired` one, where
560        // cancelling is how a client says "never mind". See
561        // `TaskState::is_cancelable`.
562        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        // Create a cancellation message to add to history
570        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        // Update the status with the cancellation message to track in history
583        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        // Persistence only: the orchestration layer announces the cancellation
591        // to streaming subscribers (see `TaskStatusBroadcast`).
592        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        // Lock order: tasks, then versions — the compare-and-swap holds both so
633        // the check and the bump are atomic against every other mutator.
634        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        // Filter tasks based on parameters
665        let mut filtered_tasks: Vec<_> = tasks_guard
666            .values()
667            .filter(|task| {
668                // Filter by context_id if provided
669                if let Some(ref context_id) = params.context_id
670                    && &task.context_id != context_id
671                {
672                    return false;
673                }
674
675                // Filter by status if provided
676                if let Some(ref status) = params.status
677                    && &task.status.state != status
678                {
679                    return false;
680                }
681
682                // Filter by status_timestamp_after if provided
683                if let Some(status_timestamp_after) = &params.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        // Sort by timestamp (most recent first)
698        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        // Handle pagination
715        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            // Parse page token as a number (simple implementation)
718            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        // Get the page of tasks
727        let mut page_tasks: Vec<_> = filtered_tasks[page_start..page_end].to_vec();
728
729        // Apply history length limit
730        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            // Remove artifacts if not requested
735            if !params.include_artifacts.unwrap_or(false) {
736                task.artifacts.clear();
737            }
738        }
739
740        // Generate next page token
741        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// AsyncNotificationManager implementation.
757//
758// In-memory storage keeps a single config per task in the push-notification
759// registry, so the multi-config CRUD surface is expressed in those terms.
760#[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        // Register with the push notification registry
774        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(&params.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        // In-memory storage supports one config per task; return it as a
806        // single-item vec (or empty if none registered).
807        match self
808            .push_notification_registry
809            .get_config(&params.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        // In-memory storage keeps a single config per task, so config_id is
822        // not used for lookup. Idempotent per the v1.0.0 spec.
823        self.push_notification_registry
824            .unregister(&params.id)
825            .await?;
826        Ok(())
827    }
828}
829
830#[async_trait]
831impl AsyncRetention for InMemoryTaskStorage {
832    /// Sweep under one set of guards.
833    ///
834    /// Every map is locked for the whole sweep, in the order documented on
835    /// `conversations`. Phasing it — pick the ids, release, then delete — would
836    /// let a turn arrive on a context between the two and leave that context
837    /// with its tasks deleted and its conversation intact. A sweep runs once a
838    /// night against contexts nothing has touched for days, so holding the
839    /// store still for it costs nothing anyone will notice.
840    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            // A context is idle when its last write is older than the cutoff.
853            // One with no entry at all was never written and has nothing to
854            // sweep, so it is skipped rather than treated as infinitely old.
855            let idle: Vec<String> = touched
856                .iter()
857                .filter(|(_, at)| **at < cutoff)
858                .map(|(context_id, _)| context_id.clone())
859                .collect();
860
861            // Every task of every idle context, in one pass over `tasks` rather
862            // than one pass per context.
863            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                // Leave a context alone while anything in it might still be
876                // running. `is_settled` counts `input-required` and
877                // `auth-required` as settled — those wait on a caller, and after
878                // the retention window the caller is not coming back — so this
879                // holds back exactly `submitted`, `working`, and a state this
880                // build cannot read.
881                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            // `by_context` borrows `tasks`, which the deletes below need mutably.
893            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                    // The SQL sweep deletes `push_notification_configs` with the
900                    // task; this is the same delete. A webhook left registered
901                    // against a task that no longer exists is a URL the agent
902                    // would keep as long as the process lives. The registry has
903                    // a lock of its own and takes none of the store's, so
904                    // calling it under these guards cannot deadlock.
905                    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    /// Ageing a bag needs a timestamp in the past, which no port lets a caller
977    /// write — so the half of `ReadRefresh` that says a read *saves* something
978    /// is asserted here, against `principal_touched` directly. The shared body
979    /// in `tests/context_state_test.rs` covers what a caller can reach.
980    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    /// The bound the design claims: however often it is read, a bag is written
1020    /// at most once per window. A second read straight after the first finds it
1021    /// fresh and leaves it alone.
1022    #[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    /// The default, from the inside: a week-old bag stays week-old however
1051    /// often it is read.
1052    #[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    /// A principal that never wrote a `user:` key has no bag, and a refresh
1075    /// must not invent one — an entry here is a claim that a write happened.
1076    #[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    /// The conversation is the messages of every task in a context, in the order
1113    /// they were recorded. Two tasks, because that is what a multi-turn
1114    /// conversation actually looks like: one task per turn, sharing a context.
1115    #[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    /// A status transition with no message has nothing to record. Storing a
1146    /// placeholder would put an empty turn in the model's prompt.
1147    #[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    /// Contexts do not leak into one another. This is the whole reason the log
1160    /// is keyed by context rather than kept per handler.
1161    #[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    /// A digest hides everything at or below its watermark, and the tail picks
1180    /// up after it. Loading the summarized part again would double the tokens
1181    /// compaction was meant to save.
1182    #[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    /// Two turns of one conversation can compact at the same time. Both digests
1215    /// land, and the one covering more wins — the reason digests append with a
1216    /// watermark instead of updating in place.
1217    #[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        // The wider digest is written first, so "newest row wins" would pick the
1230        // narrow one and re-feed a message the summary already covers.
1231        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    /// Limiting keeps the newest. The older end is what a summary stands in for,
1256    /// so truncating there would leave the model the least relevant half.
1257    #[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    /// Reading a conversation back turns `context_id` into a capability: whoever
1273    /// holds one would otherwise read what was said in it.
1274    #[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        // First read claims it.
1284        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        // And compacting someone else's conversation is refused the same way.
1300        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    /// An agent running without an authenticator has no principal to claim with,
1317    /// and its conversations stay readable. Refusing here would break every
1318    /// unauthenticated deployment.
1319    #[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        // Unversioned mutations bump the version, keeping the two views in sync.
1354        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        // Stale conditional update is rejected and leaves the task unchanged.
1362        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        // Current-version conditional update succeeds and bumps.
1380        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}