1use std::borrow::Cow;
22use std::future::Future;
23use std::pin::Pin;
24
25use a2a_protocol_types::error::{A2aError, A2aResult};
26use a2a_protocol_types::params::ListTasksParams;
27use a2a_protocol_types::responses::TaskListResponse;
28use a2a_protocol_types::task::{Task, TaskId};
29use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
30
31use super::task_store::{ArtifactDelta, TaskStore};
32
33#[derive(Debug, Clone)]
57pub struct SqliteTaskStore {
58 pool: SqlitePool,
59}
60
61impl SqliteTaskStore {
62 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
68 let pool = sqlite_pool(url).await?;
69 Self::from_pool(pool).await
70 }
71
72 pub async fn with_migrations(url: &str) -> Result<Self, sqlx::Error> {
82 let pool = sqlite_pool(url).await?;
83
84 let runner = super::migration::MigrationRunner::new(pool.clone());
85 runner.run_pending().await?;
86
87 Ok(Self { pool })
88 }
89
90 pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
96 sqlx::query(
97 "CREATE TABLE IF NOT EXISTS tasks (
98 id TEXT PRIMARY KEY,
99 context_id TEXT NOT NULL,
100 state TEXT NOT NULL,
101 data TEXT NOT NULL,
102 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
103 created_at TEXT NOT NULL DEFAULT (datetime('now'))
104 )",
105 )
106 .execute(&pool)
107 .await?;
108
109 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_context_id ON tasks(context_id)")
110 .execute(&pool)
111 .await?;
112
113 sqlx::query("CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state)")
114 .execute(&pool)
115 .await?;
116
117 sqlx::query(
118 "CREATE INDEX IF NOT EXISTS idx_tasks_context_id_state ON tasks(context_id, state)",
119 )
120 .execute(&pool)
121 .await?;
122
123 sqlx::query(
126 "CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC, id DESC)",
127 )
128 .execute(&pool)
129 .await?;
130
131 Ok(Self { pool })
132 }
133}
134
135async fn sqlite_pool(url: &str) -> Result<SqlitePool, sqlx::Error> {
140 sqlite_pool_with_size(url, 8).await
141}
142
143async fn sqlite_pool_with_size(url: &str, max_connections: u32) -> Result<SqlitePool, sqlx::Error> {
145 use sqlx::sqlite::SqliteConnectOptions;
146 use std::str::FromStr;
147
148 let opts = SqliteConnectOptions::from_str(url)?
149 .pragma("journal_mode", "WAL")
150 .pragma("busy_timeout", "5000")
151 .pragma("synchronous", "NORMAL")
152 .pragma("foreign_keys", "ON")
153 .create_if_missing(true);
154
155 SqlitePoolOptions::new()
156 .max_connections(max_connections)
157 .connect_with(opts)
158 .await
159}
160
161#[allow(clippy::needless_pass_by_value)]
163fn to_a2a_error(e: sqlx::Error) -> A2aError {
164 A2aError::internal(format!("sqlite error: {e}"))
165}
166
167const MAX_INLINE_APPEND: usize = 8;
195
196fn artifact_delta_sql(task: &Task, delta: ArtifactDelta) -> A2aResult<Option<DeltaStatement>> {
197 let Some(artifacts) = task.artifacts.as_ref() else {
198 return Ok(None);
199 };
200
201 match delta {
202 ArtifactDelta::AppendedParts { index, count } => {
203 if count == 0 || count > MAX_INLINE_APPEND {
204 return Ok(None);
205 }
206 let Some(artifact) = artifacts.get(index) else {
207 return Ok(None);
208 };
209 if artifact.parts.len() < count {
210 return Ok(None);
211 }
212 let tail = &artifact.parts[artifact.parts.len() - count..];
213 let payload = serde_json::to_string(tail)
214 .map_err(|e| A2aError::internal(format!("failed to serialize parts: {e}")))?;
215
216 if count == 1 {
217 return Ok(Some(DeltaStatement {
226 sql: APPEND_ONE_PART_SQL,
227 payload,
228 index: Some(index),
229 }));
230 }
231
232 let exprs = (0..count)
235 .map(|i| format!("'$.artifacts[{index}].parts[#]', json_extract(?1, '$[{i}]')"))
236 .collect::<Vec<_>>()
237 .join(", ");
238 Ok(Some(DeltaStatement {
239 sql: Cow::Owned(format!(
240 "UPDATE tasks SET data = json_set(data, {exprs}) \
241 WHERE id = ?2 AND json_type(data, '$.artifacts') = 'array'"
242 )),
243 payload,
244 index: None,
245 }))
246 }
247 ArtifactDelta::Pushed { index } => {
248 if index + 1 != artifacts.len() {
249 return Ok(None);
250 }
251 let Some(artifact) = artifacts.get(index) else {
252 return Ok(None);
253 };
254 let payload = serde_json::to_string(std::slice::from_ref(artifact))
255 .map_err(|e| A2aError::internal(format!("failed to serialize artifact: {e}")))?;
256 Ok(Some(DeltaStatement {
257 sql: PUSH_ARTIFACT_SQL,
258 payload,
259 index: None,
260 }))
261 }
262 }
263}
264
265struct DeltaStatement {
268 sql: Cow<'static, str>,
269 payload: String,
270 index: Option<usize>,
271}
272
273const APPEND_ONE_PART_SQL: Cow<'static, str> = Cow::Borrowed(
285 "UPDATE tasks SET data = json_set(data, '$.artifacts[' || ?3 || '].parts[#]', \
286 json_extract(?1, '$[0]')) \
287 WHERE id = ?2 AND json_type(data, '$.artifacts') = 'array'",
288);
289
290const PUSH_ARTIFACT_SQL: Cow<'static, str> = Cow::Borrowed(
292 "UPDATE tasks SET data = json_set(data, '$.artifacts[#]', json_extract(?1, '$[0]')) \
293 WHERE id = ?2 AND json_type(data, '$.artifacts') = 'array'",
294);
295
296#[allow(clippy::manual_async_fn)]
297impl TaskStore for SqliteTaskStore {
298 fn save<'a>(
299 &'a self,
300 task: &'a Task,
301 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
302 Box::pin(async move {
303 let id = task.id.0.as_str();
304 let context_id = task.context_id.0.as_str();
305 let state = task.status.state.to_string();
306 let data = serde_json::to_string(task)
307 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
308 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
312
313 sqlx::query(
314 "INSERT INTO tasks (id, context_id, state, data, updated_at)
315 VALUES (?1, ?2, ?3, ?4, COALESCE(?5, strftime('%Y-%m-%d %H:%M:%f','now')))
316 ON CONFLICT(id) DO UPDATE SET
317 context_id = excluded.context_id,
318 state = excluded.state,
319 data = excluded.data,
320 updated_at = excluded.updated_at",
321 )
322 .bind(id)
323 .bind(context_id)
324 .bind(&state)
325 .bind(&data)
326 .bind(&status_ts)
327 .execute(&self.pool)
328 .await
329 .map_err(to_a2a_error)?;
330
331 Ok(())
332 })
333 }
334
335 fn save_artifact_delta<'a>(
365 &'a self,
366 task: &'a Task,
367 delta: ArtifactDelta,
368 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
369 Box::pin(async move {
370 let Some(stmt) = artifact_delta_sql(task, delta)? else {
371 return self.save(task).await;
372 };
373
374 let mut query = sqlx::query(stmt.sql.as_ref())
375 .bind(&stmt.payload)
376 .bind(task.id.0.as_str());
377 if let Some(index) = stmt.index {
380 query = query.bind(i64::try_from(index).unwrap_or(i64::MAX));
381 }
382
383 let affected = query
384 .execute(&self.pool)
385 .await
386 .map_err(to_a2a_error)?
387 .rows_affected();
388
389 if affected == 0 {
392 return self.save(task).await;
393 }
394
395 Ok(())
396 })
397 }
398
399 fn get<'a>(
400 &'a self,
401 id: &'a TaskId,
402 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
403 Box::pin(async move {
404 let row: Option<(String,)> = sqlx::query_as("SELECT data FROM tasks WHERE id = ?1")
405 .bind(id.0.as_str())
406 .fetch_optional(&self.pool)
407 .await
408 .map_err(to_a2a_error)?;
409
410 match row {
411 Some((data,)) => {
412 let task: Task = serde_json::from_str(&data).map_err(|e| {
413 A2aError::internal(format!("failed to deserialize task: {e}"))
414 })?;
415 Ok(Some(task))
416 }
417 None => Ok(None),
418 }
419 })
420 }
421
422 #[allow(clippy::too_many_lines)]
423 fn list<'a>(
424 &'a self,
425 params: &'a ListTasksParams,
426 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
427 Box::pin(async move {
428 let mut conditions = Vec::new();
430 let mut bind_values: Vec<String> = Vec::new();
431
432 if let Some(ref ctx) = params.context_id {
433 conditions.push(format!("context_id = ?{}", bind_values.len() + 1));
434 bind_values.push(ctx.clone());
435 }
436 if let Some(ref status) = params.status {
437 conditions.push(format!("state = ?{}", bind_values.len() + 1));
438 bind_values.push(status.to_string());
439 }
440 if let Some(ref after) = params.status_timestamp_after {
446 let Some(after_dt) = super::status_timestamp_sqlite(Some(after)) else {
447 return Ok(TaskListResponse::new(Vec::new()));
448 };
449 conditions.push(format!("updated_at > ?{}", bind_values.len() + 1));
450 bind_values.push(after_dt);
451 }
452 if let Some(ref token) = params.page_token {
457 let Some((cursor_ua, cursor_id)) = super::cursor::decode(token) else {
458 return Ok(TaskListResponse::new(Vec::new()));
459 };
460 let p = bind_values.len();
461 conditions.push(format!("(updated_at, id) < (?{}, ?{})", p + 1, p + 2));
462 bind_values.push(cursor_ua.to_string());
463 bind_values.push(cursor_id.to_string());
464 }
465
466 let where_clause = if conditions.is_empty() {
467 String::new()
468 } else {
469 format!("WHERE {}", conditions.join(" AND "))
470 };
471
472 let page_size = match params.page_size {
473 Some(0) | None => 50_u32,
474 Some(n) => n.min(1000),
475 };
476
477 let limit = super::pagination::fetch_limit(page_size);
480 let limit_param = bind_values.len() + 1;
481 let sql = format!(
482 "SELECT updated_at, data FROM tasks {where_clause} \
483 ORDER BY updated_at DESC, id DESC LIMIT ?{limit_param}"
484 );
485
486 let mut query = sqlx::query_as::<_, (String, String)>(&sql);
487 for val in &bind_values {
488 query = query.bind(val);
489 }
490 query = query.bind(limit);
491
492 let rows: Vec<(String, String)> =
493 query.fetch_all(&self.pool).await.map_err(to_a2a_error)?;
494
495 let mut rows: Vec<(String, Task)> = rows
496 .into_iter()
497 .map(|(updated_at, data)| {
498 serde_json::from_str::<Task>(&data)
499 .map(|task| (updated_at, task))
500 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
501 })
502 .collect::<A2aResult<Vec<_>>>()?;
503
504 let next_page_token =
505 if super::pagination::has_next_page(rows.len(), page_size as usize) {
506 rows.truncate(page_size as usize);
507 rows.last()
508 .map(|(ua, task)| super::cursor::encode(ua, task.id.0.as_str()))
509 .unwrap_or_default()
510 } else {
511 String::new()
512 };
513
514 #[allow(clippy::cast_possible_truncation)]
515 let page_len = rows.len() as u32;
516 let tasks: Vec<Task> = rows.into_iter().map(|(_, task)| task).collect();
517 let mut response = TaskListResponse::new(tasks);
518 response.next_page_token = next_page_token;
519 response.page_size = page_len;
520 Ok(response)
521 })
522 }
523
524 fn insert_if_absent<'a>(
525 &'a self,
526 task: &'a Task,
527 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
528 Box::pin(async move {
529 let id = task.id.0.as_str();
530 let context_id = task.context_id.0.as_str();
531 let state = task.status.state.to_string();
532 let data = serde_json::to_string(task)
533 .map_err(|e| A2aError::internal(format!("failed to serialize task: {e}")))?;
534
535 let status_ts = super::status_timestamp_sqlite(task.status.timestamp.as_deref());
536 let result = sqlx::query(
537 "INSERT OR IGNORE INTO tasks (id, context_id, state, data, updated_at)
538 VALUES (?1, ?2, ?3, ?4, COALESCE(?5, strftime('%Y-%m-%d %H:%M:%f','now')))",
539 )
540 .bind(id)
541 .bind(context_id)
542 .bind(&state)
543 .bind(&data)
544 .bind(&status_ts)
545 .execute(&self.pool)
546 .await
547 .map_err(to_a2a_error)?;
548
549 Ok(result.rows_affected() > 0)
550 })
551 }
552
553 fn delete<'a>(
554 &'a self,
555 id: &'a TaskId,
556 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
557 Box::pin(async move {
558 sqlx::query("DELETE FROM tasks WHERE id = ?1")
559 .bind(id.0.as_str())
560 .execute(&self.pool)
561 .await
562 .map_err(to_a2a_error)?;
563 Ok(())
564 })
565 }
566
567 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
568 Box::pin(async move {
569 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tasks")
570 .fetch_one(&self.pool)
571 .await
572 .map_err(to_a2a_error)?;
573 #[allow(clippy::cast_sign_loss)]
574 Ok(row.0 as u64)
575 })
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use a2a_protocol_types::artifact::Artifact;
583 use a2a_protocol_types::message::Part;
584 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
585
586 async fn make_store() -> SqliteTaskStore {
587 SqliteTaskStore::new("sqlite::memory:")
588 .await
589 .expect("failed to create in-memory store")
590 }
591
592 fn make_task(id: &str, ctx: &str, state: TaskState) -> Task {
593 Task {
594 id: TaskId::new(id),
595 context_id: ContextId::new(ctx),
596 status: TaskStatus::new(state),
597 history: None,
598 artifacts: None,
599 metadata: None,
600 }
601 }
602
603 fn task_with_parts(parts: usize) -> Task {
616 let mut task = make_task("t-delta", "c-delta", TaskState::Working);
617 task.artifacts = Some(vec![Artifact::new(
618 "art",
619 (0..parts)
620 .map(|i| Part::text(format!("p{i}")))
621 .collect::<Vec<_>>(),
622 )]);
623 task
624 }
625
626 #[test]
633 fn append_batch_cutoff_is_exactly_max_inline_append() {
634 let task = task_with_parts(MAX_INLINE_APPEND);
636 let at = artifact_delta_sql(
637 &task,
638 ArtifactDelta::AppendedParts {
639 index: 0,
640 count: MAX_INLINE_APPEND,
641 },
642 )
643 .expect("no error");
644 assert!(
645 at.is_some(),
646 "a batch of exactly MAX_INLINE_APPEND must use the incremental path"
647 );
648
649 let task = task_with_parts(MAX_INLINE_APPEND + 1);
651 let over = artifact_delta_sql(
652 &task,
653 ArtifactDelta::AppendedParts {
654 index: 0,
655 count: MAX_INLINE_APPEND + 1,
656 },
657 )
658 .expect("no error");
659 assert!(
660 over.is_none(),
661 "a batch larger than MAX_INLINE_APPEND must fall back to a full save"
662 );
663
664 let task = task_with_parts(2);
666 let under = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 2 })
667 .expect("no error");
668 assert!(
669 under.is_some(),
670 "a small batch must use the incremental path"
671 );
672 }
673
674 #[test]
683 fn delta_claiming_all_parts_is_accepted_and_overclaiming_is_not() {
684 let task = task_with_parts(3);
685
686 let exact = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 3 })
687 .expect("no error");
688 assert!(
689 exact.is_some(),
690 "a delta covering every part of the artifact must be accepted"
691 );
692
693 let over = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 4 })
694 .expect("no error");
695 assert!(
696 over.is_none(),
697 "a delta claiming more parts than exist must fall back"
698 );
699 }
700
701 #[test]
703 fn zero_count_delta_falls_back() {
704 let task = task_with_parts(3);
705 let none = artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 0 })
706 .expect("no error");
707 assert!(none.is_none(), "a zero-count delta must fall back");
708 }
709
710 #[test]
718 fn push_is_accepted_only_at_the_last_position() {
719 let mut task = make_task("t-push", "c-push", TaskState::Working);
720 task.artifacts = Some(vec![
721 Artifact::new("a0", vec![Part::text("x")]),
722 Artifact::new("a1", vec![Part::text("y")]),
723 ]);
724
725 let last = artifact_delta_sql(&task, ArtifactDelta::Pushed { index: 1 }).expect("no error");
726 assert!(
727 last.is_some(),
728 "pushing the artifact that is last in the vector must use the incremental path"
729 );
730
731 let not_last =
732 artifact_delta_sql(&task, ArtifactDelta::Pushed { index: 0 }).expect("no error");
733 assert!(
734 not_last.is_none(),
735 "pushing at any position but the last must fall back"
736 );
737
738 let mut single = make_task("t-one", "c-one", TaskState::Working);
741 single.artifacts = Some(vec![Artifact::new("only", vec![Part::text("z")])]);
742 let only =
743 artifact_delta_sql(&single, ArtifactDelta::Pushed { index: 0 }).expect("no error");
744 assert!(
745 only.is_some(),
746 "the sole artifact is at the last position and must be accepted"
747 );
748 }
749
750 #[test]
752 fn task_without_artifacts_always_falls_back() {
753 let task = make_task("t-empty", "c-empty", TaskState::Working);
754 assert!(
755 artifact_delta_sql(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
756 .expect("no error")
757 .is_none()
758 );
759 assert!(
760 artifact_delta_sql(&task, ArtifactDelta::Pushed { index: 0 })
761 .expect("no error")
762 .is_none()
763 );
764 }
765
766 #[tokio::test]
767 async fn save_and_get_round_trip() {
768 let store = make_store().await;
769 let task = make_task("t1", "ctx1", TaskState::Submitted);
770 store.save(&task).await.expect("save should succeed");
771
772 let retrieved = store
773 .get(&TaskId::new("t1"))
774 .await
775 .expect("get should succeed");
776 let retrieved = retrieved.expect("task should exist after save");
777 assert_eq!(retrieved.id, TaskId::new("t1"), "task id should match");
778 assert_eq!(
779 retrieved.context_id,
780 ContextId::new("ctx1"),
781 "context_id should match"
782 );
783 assert_eq!(
784 retrieved.status.state,
785 TaskState::Submitted,
786 "state should match"
787 );
788 }
789
790 #[tokio::test]
791 async fn get_returns_none_for_missing_task() {
792 let store = make_store().await;
793 let result = store
794 .get(&TaskId::new("nonexistent"))
795 .await
796 .expect("get should succeed");
797 assert!(
798 result.is_none(),
799 "get should return None for a missing task"
800 );
801 }
802
803 #[tokio::test]
804 async fn save_overwrites_existing_task() {
805 let store = make_store().await;
806 let task1 = make_task("t1", "ctx1", TaskState::Submitted);
807 store.save(&task1).await.expect("first save should succeed");
808
809 let task2 = make_task("t1", "ctx1", TaskState::Working);
810 store
811 .save(&task2)
812 .await
813 .expect("second save should succeed");
814
815 let retrieved = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
816 assert_eq!(
817 retrieved.status.state,
818 TaskState::Working,
819 "state should be updated after overwrite"
820 );
821 }
822
823 #[tokio::test]
824 async fn insert_if_absent_returns_true_for_new_task() {
825 let store = make_store().await;
826 let task = make_task("t1", "ctx1", TaskState::Submitted);
827 let inserted = store
828 .insert_if_absent(&task)
829 .await
830 .expect("insert_if_absent should succeed");
831 assert!(
832 inserted,
833 "insert_if_absent should return true for a new task"
834 );
835 }
836
837 #[tokio::test]
838 async fn insert_if_absent_returns_false_for_existing_task() {
839 let store = make_store().await;
840 let task = make_task("t1", "ctx1", TaskState::Submitted);
841 store.save(&task).await.unwrap();
842
843 let duplicate = make_task("t1", "ctx1", TaskState::Working);
844 let inserted = store
845 .insert_if_absent(&duplicate)
846 .await
847 .expect("insert_if_absent should succeed");
848 assert!(
849 !inserted,
850 "insert_if_absent should return false for an existing task"
851 );
852
853 let retrieved = store.get(&TaskId::new("t1")).await.unwrap().unwrap();
855 assert_eq!(
856 retrieved.status.state,
857 TaskState::Submitted,
858 "original state should be preserved"
859 );
860 }
861
862 #[tokio::test]
863 async fn delete_removes_task() {
864 let store = make_store().await;
865 store
866 .save(&make_task("t1", "ctx1", TaskState::Submitted))
867 .await
868 .unwrap();
869
870 store
871 .delete(&TaskId::new("t1"))
872 .await
873 .expect("delete should succeed");
874
875 let result = store.get(&TaskId::new("t1")).await.unwrap();
876 assert!(result.is_none(), "task should be gone after delete");
877 }
878
879 #[tokio::test]
880 async fn delete_nonexistent_is_ok() {
881 let store = make_store().await;
882 let result = store.delete(&TaskId::new("nonexistent")).await;
883 assert!(
884 result.is_ok(),
885 "deleting a nonexistent task should not error"
886 );
887 }
888
889 #[tokio::test]
890 async fn count_tracks_inserts_and_deletes() {
891 let store = make_store().await;
892 assert_eq!(
893 store.count().await.unwrap(),
894 0,
895 "empty store should have count 0"
896 );
897
898 store
899 .save(&make_task("t1", "ctx1", TaskState::Submitted))
900 .await
901 .unwrap();
902 store
903 .save(&make_task("t2", "ctx1", TaskState::Working))
904 .await
905 .unwrap();
906 assert_eq!(
907 store.count().await.unwrap(),
908 2,
909 "count should be 2 after two saves"
910 );
911
912 store.delete(&TaskId::new("t1")).await.unwrap();
913 assert_eq!(
914 store.count().await.unwrap(),
915 1,
916 "count should be 1 after one delete"
917 );
918 }
919
920 #[tokio::test]
921 async fn list_all_tasks() {
922 let store = make_store().await;
923 store
924 .save(&make_task("t1", "ctx1", TaskState::Submitted))
925 .await
926 .unwrap();
927 store
928 .save(&make_task("t2", "ctx2", TaskState::Working))
929 .await
930 .unwrap();
931
932 let params = ListTasksParams::default();
933 let response = store.list(¶ms).await.expect("list should succeed");
934 assert_eq!(response.tasks.len(), 2, "list should return all tasks");
935 }
936
937 #[tokio::test]
938 async fn list_filter_by_context_id() {
939 let store = make_store().await;
940 store
941 .save(&make_task("t1", "ctx-a", TaskState::Submitted))
942 .await
943 .unwrap();
944 store
945 .save(&make_task("t2", "ctx-b", TaskState::Submitted))
946 .await
947 .unwrap();
948 store
949 .save(&make_task("t3", "ctx-a", TaskState::Working))
950 .await
951 .unwrap();
952
953 let params = ListTasksParams {
954 context_id: Some("ctx-a".to_string()),
955 ..Default::default()
956 };
957 let response = store.list(¶ms).await.unwrap();
958 assert_eq!(
959 response.tasks.len(),
960 2,
961 "should return only tasks with context_id ctx-a"
962 );
963 }
964
965 #[tokio::test]
966 async fn list_filter_by_status() {
967 let store = make_store().await;
968 store
969 .save(&make_task("t1", "ctx1", TaskState::Submitted))
970 .await
971 .unwrap();
972 store
973 .save(&make_task("t2", "ctx1", TaskState::Working))
974 .await
975 .unwrap();
976 store
977 .save(&make_task("t3", "ctx1", TaskState::Working))
978 .await
979 .unwrap();
980
981 let params = ListTasksParams {
982 status: Some(TaskState::Working),
983 ..Default::default()
984 };
985 let response = store.list(¶ms).await.unwrap();
986 assert_eq!(response.tasks.len(), 2, "should return only Working tasks");
987 }
988
989 #[tokio::test]
990 async fn list_pagination() {
991 let store = make_store().await;
992 for i in 0..5 {
994 store
995 .save(&make_task(
996 &format!("task-{i:03}"),
997 "ctx1",
998 TaskState::Submitted,
999 ))
1000 .await
1001 .unwrap();
1002 }
1003
1004 let params = ListTasksParams {
1006 page_size: Some(2),
1007 ..Default::default()
1008 };
1009 let response = store.list(¶ms).await.unwrap();
1010 assert_eq!(response.tasks.len(), 2, "first page should have 2 tasks");
1011 assert!(
1012 !response.next_page_token.is_empty(),
1013 "should have a next page token"
1014 );
1015
1016 let params2 = ListTasksParams {
1018 page_size: Some(2),
1019 page_token: Some(response.next_page_token),
1020 ..Default::default()
1021 };
1022 let response2 = store.list(¶ms2).await.unwrap();
1023 assert_eq!(response2.tasks.len(), 2, "second page should have 2 tasks");
1024 assert!(
1025 !response2.next_page_token.is_empty(),
1026 "should still have a next page token"
1027 );
1028
1029 let params3 = ListTasksParams {
1031 page_size: Some(2),
1032 page_token: Some(response2.next_page_token),
1033 ..Default::default()
1034 };
1035 let response3 = store.list(¶ms3).await.unwrap();
1036 assert_eq!(response3.tasks.len(), 1, "last page should have 1 task");
1037 assert!(
1038 response3.next_page_token.is_empty(),
1039 "last page should have no next page token"
1040 );
1041 }
1042
1043 #[tokio::test]
1044 async fn list_orders_most_recently_updated_first() {
1045 let store = make_store().await;
1046 for id in ["c", "a", "b"] {
1049 store
1050 .save(&make_task(id, "ctx1", TaskState::Submitted))
1051 .await
1052 .unwrap();
1053 tokio::time::sleep(std::time::Duration::from_millis(3)).await;
1054 }
1055
1056 let response = store.list(&ListTasksParams::default()).await.unwrap();
1057 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
1058 assert_eq!(
1059 ids,
1060 vec!["b", "a", "c"],
1061 "tasks should be ordered most-recently-updated first"
1062 );
1063 }
1064
1065 fn make_task_with_ts(id: &str, ctx: &str, state: TaskState, ts: &str) -> Task {
1067 let mut task = make_task(id, ctx, state);
1068 task.status.timestamp = Some(ts.to_owned());
1069 task
1070 }
1071
1072 #[tokio::test]
1075 async fn list_orders_by_status_timestamp_not_write_order() {
1076 let store = make_store().await;
1077 for (id, ts) in [
1079 ("middle", "2026-01-02T00:00:00.000Z"),
1080 ("newest", "2026-01-03T00:00:00.000Z"),
1081 ("oldest", "2026-01-01T00:00:00.000Z"),
1082 ] {
1083 store
1084 .save(&make_task_with_ts(id, "ctx1", TaskState::Working, ts))
1085 .await
1086 .unwrap();
1087 }
1088
1089 let response = store.list(&ListTasksParams::default()).await.unwrap();
1090 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
1091 assert_eq!(
1092 ids,
1093 vec!["newest", "middle", "oldest"],
1094 "list must sort by status timestamp descending"
1095 );
1096 }
1097
1098 #[tokio::test]
1101 async fn list_resave_without_status_change_keeps_position() {
1102 let store = make_store().await;
1103 store
1104 .save(&make_task_with_ts(
1105 "older",
1106 "ctx1",
1107 TaskState::Working,
1108 "2026-01-01T00:00:00.000Z",
1109 ))
1110 .await
1111 .unwrap();
1112 store
1113 .save(&make_task_with_ts(
1114 "newer",
1115 "ctx1",
1116 TaskState::Working,
1117 "2026-01-02T00:00:00.000Z",
1118 ))
1119 .await
1120 .unwrap();
1121
1122 store
1124 .save(&make_task_with_ts(
1125 "older",
1126 "ctx1",
1127 TaskState::Working,
1128 "2026-01-01T00:00:00.000Z",
1129 ))
1130 .await
1131 .unwrap();
1132
1133 let response = store.list(&ListTasksParams::default()).await.unwrap();
1134 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
1135 assert_eq!(
1136 ids,
1137 vec!["newer", "older"],
1138 "a status-preserving re-save must not reorder the list"
1139 );
1140 }
1141
1142 #[tokio::test]
1144 async fn list_filters_by_status_timestamp_after() {
1145 let store = make_store().await;
1146 for (id, ts) in [
1147 ("old", "2026-01-01T00:00:00.000Z"),
1148 ("boundary", "2026-01-02T00:00:00.000Z"),
1149 ("new", "2026-01-03T00:00:00.000Z"),
1150 ] {
1151 store
1152 .save(&make_task_with_ts(id, "ctx1", TaskState::Working, ts))
1153 .await
1154 .unwrap();
1155 }
1156
1157 let params = ListTasksParams {
1158 status_timestamp_after: Some("2026-01-02T00:00:00.000Z".into()),
1159 ..Default::default()
1160 };
1161 let response = store.list(¶ms).await.unwrap();
1162 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
1163 assert_eq!(
1164 ids,
1165 vec!["new"],
1166 "filter must be strictly-after (boundary excluded)"
1167 );
1168 }
1169
1170 #[tokio::test]
1171 async fn list_reorders_on_update() {
1172 let store = make_store().await;
1173 for id in ["t1", "t2", "t3"] {
1174 store
1175 .save(&make_task(id, "ctx1", TaskState::Submitted))
1176 .await
1177 .unwrap();
1178 tokio::time::sleep(std::time::Duration::from_millis(3)).await;
1179 }
1180
1181 store
1183 .save(&make_task("t1", "ctx1", TaskState::Working))
1184 .await
1185 .unwrap();
1186
1187 let response = store.list(&ListTasksParams::default()).await.unwrap();
1188 let ids: Vec<&str> = response.tasks.iter().map(|t| t.id.0.as_str()).collect();
1189 assert_eq!(
1190 ids,
1191 vec!["t1", "t3", "t2"],
1192 "an updated task must move to the front of the update order"
1193 );
1194 }
1195
1196 #[tokio::test]
1197 async fn list_pagination_visits_every_task_once() {
1198 let store = make_store().await;
1202 for i in 0..25 {
1203 store
1204 .save(&make_task(
1205 &format!("t{i:03}"),
1206 "ctx1",
1207 TaskState::Submitted,
1208 ))
1209 .await
1210 .unwrap();
1211 }
1212
1213 let mut seen = std::collections::HashSet::new();
1214 let mut token: Option<String> = None;
1215 loop {
1216 let params = ListTasksParams {
1217 page_size: Some(4),
1218 page_token: token.clone(),
1219 ..Default::default()
1220 };
1221 let page = store.list(¶ms).await.unwrap();
1222 for t in &page.tasks {
1223 assert!(seen.insert(t.id.0.clone()), "task {} seen twice", t.id.0);
1224 }
1225 if page.next_page_token.is_empty() {
1226 break;
1227 }
1228 token = Some(page.next_page_token);
1229 }
1230 assert_eq!(seen.len(), 25, "every task must be visited exactly once");
1231 }
1232
1233 #[tokio::test]
1234 async fn list_malformed_page_token_returns_empty() {
1235 let store = make_store().await;
1236 store
1237 .save(&make_task("t1", "ctx1", TaskState::Submitted))
1238 .await
1239 .unwrap();
1240
1241 let params = ListTasksParams {
1244 page_token: Some("forged-cursor-no-separator".to_string()),
1245 ..Default::default()
1246 };
1247 let response = store.list(¶ms).await.unwrap();
1248 assert!(
1249 response.tasks.is_empty(),
1250 "malformed page_token should yield empty results"
1251 );
1252 }
1253
1254 #[test]
1256 fn to_a2a_error_formats_message() {
1257 let sqlite_err = sqlx::Error::RowNotFound;
1258 let a2a_err = to_a2a_error(sqlite_err);
1259 let msg = format!("{a2a_err}");
1260 assert!(
1261 msg.contains("sqlite error"),
1262 "error message should contain 'sqlite error': {msg}"
1263 );
1264 }
1265
1266 #[tokio::test]
1268 async fn with_migrations_creates_store() {
1269 let result = SqliteTaskStore::with_migrations("sqlite::memory:").await;
1271 assert!(
1272 result.is_ok(),
1273 "with_migrations should succeed on a fresh database"
1274 );
1275 let store = result.unwrap();
1276 let count = store.count().await.unwrap();
1277 assert_eq!(count, 0, "freshly migrated store should be empty");
1278 }
1279
1280 #[tokio::test]
1281 async fn list_empty_store() {
1282 let store = make_store().await;
1283 let params = ListTasksParams::default();
1284 let response = store.list(¶ms).await.unwrap();
1285 assert!(
1286 response.tasks.is_empty(),
1287 "list on empty store should return no tasks"
1288 );
1289 assert!(
1290 response.next_page_token.is_empty(),
1291 "no pagination token for empty results"
1292 );
1293 }
1294}
1295
1296#[cfg(test)]
1306mod artifact_delta_tests {
1307 use super::*;
1308 use a2a_protocol_types::artifact::Artifact;
1309 use a2a_protocol_types::message::Part;
1310 use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
1311
1312 async fn stores() -> (SqliteTaskStore, SqliteTaskStore) {
1313 (
1314 SqliteTaskStore::new("sqlite::memory:")
1315 .await
1316 .expect("delta"),
1317 SqliteTaskStore::new("sqlite::memory:").await.expect("save"),
1318 )
1319 }
1320
1321 fn task_with(id: &str, artifacts: Option<Vec<Artifact>>) -> Task {
1322 Task {
1323 id: TaskId::new(id),
1324 context_id: ContextId::new("ctx"),
1325 status: TaskStatus::new(TaskState::Working),
1326 history: None,
1327 artifacts,
1328 metadata: None,
1329 }
1330 }
1331
1332 fn artifact(id: &str, parts: usize) -> Artifact {
1333 Artifact::new(
1334 id,
1335 (0..parts).map(|i| Part::text(format!("p{i}"))).collect(),
1336 )
1337 }
1338
1339 #[tokio::test]
1342 async fn appending_matches_full_save_at_every_step() {
1343 let (delta_store, save_store) = stores().await;
1344 let mut task = task_with("t", Some(vec![artifact("a", 1)]));
1345 delta_store.save(&task).await.unwrap();
1346 save_store.save(&task).await.unwrap();
1347
1348 for i in 0..120 {
1349 task.artifacts.as_mut().unwrap()[0]
1350 .parts
1351 .push(Part::text(format!("chunk{i}")));
1352
1353 delta_store
1354 .save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
1355 .await
1356 .unwrap();
1357 save_store.save(&task).await.unwrap();
1358
1359 let id = TaskId::new("t");
1360 assert_eq!(
1361 delta_store.get(&id).await.unwrap(),
1362 save_store.get(&id).await.unwrap(),
1363 "diverged after {i} appends"
1364 );
1365 }
1366 }
1367
1368 #[tokio::test]
1371 async fn multi_part_append_preserves_order() {
1372 let (store, _) = stores().await;
1373 let mut task = task_with("t", Some(vec![artifact("a", 1)]));
1374 store.save(&task).await.unwrap();
1375
1376 let added = vec![
1377 Part::text("first"),
1378 Part::text("second"),
1379 Part::text("third"),
1380 ];
1381 task.artifacts.as_mut().unwrap()[0]
1382 .parts
1383 .extend(added.clone());
1384 store
1385 .save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 3 })
1386 .await
1387 .unwrap();
1388
1389 assert_eq!(store.get(&TaskId::new("t")).await.unwrap(), Some(task));
1390 }
1391
1392 #[tokio::test]
1394 async fn pushing_matches_full_save_at_every_step() {
1395 let (delta_store, save_store) = stores().await;
1396 let mut task = task_with("t", Some(vec![]));
1397 delta_store.save(&task).await.unwrap();
1398 save_store.save(&task).await.unwrap();
1399
1400 for i in 0..60 {
1401 task.artifacts
1402 .as_mut()
1403 .unwrap()
1404 .push(artifact(&format!("a{i}"), 2));
1405 let index = task.artifacts.as_ref().unwrap().len() - 1;
1406
1407 delta_store
1408 .save_artifact_delta(&task, ArtifactDelta::Pushed { index })
1409 .await
1410 .unwrap();
1411 save_store.save(&task).await.unwrap();
1412
1413 let id = TaskId::new("t");
1414 assert_eq!(
1415 delta_store.get(&id).await.unwrap(),
1416 save_store.get(&id).await.unwrap(),
1417 "diverged after {i} pushes"
1418 );
1419 }
1420 }
1421
1422 #[tokio::test]
1424 async fn absent_row_falls_back_to_full_save() {
1425 let (store, _) = stores().await;
1426 let task = task_with("never-saved", Some(vec![artifact("a", 3)]));
1427
1428 store
1429 .save_artifact_delta(&task, ArtifactDelta::Pushed { index: 0 })
1430 .await
1431 .unwrap();
1432
1433 assert_eq!(
1434 store.get(&TaskId::new("never-saved")).await.unwrap(),
1435 Some(task)
1436 );
1437 }
1438
1439 #[tokio::test]
1443 async fn stored_task_without_artifacts_falls_back() {
1444 let (store, _) = stores().await;
1445 let mut task = task_with("t", None);
1446 store.save(&task).await.unwrap();
1447
1448 task.artifacts = Some(vec![artifact("a", 2)]);
1449 store
1450 .save_artifact_delta(&task, ArtifactDelta::Pushed { index: 0 })
1451 .await
1452 .unwrap();
1453
1454 assert_eq!(store.get(&TaskId::new("t")).await.unwrap(), Some(task));
1455 }
1456
1457 #[tokio::test]
1460 async fn inconsistent_deltas_fall_back_and_stay_correct() {
1461 for delta in [
1462 ArtifactDelta::AppendedParts { index: 9, count: 1 }, ArtifactDelta::AppendedParts {
1464 index: 0,
1465 count: 99,
1466 }, ArtifactDelta::AppendedParts { index: 0, count: 0 }, ArtifactDelta::Pushed { index: 7 }, ] {
1470 let (store, _) = stores().await;
1471 let mut task = task_with("t", Some(vec![artifact("a", 1)]));
1472 store.save(&task).await.unwrap();
1473
1474 task.artifacts.as_mut().unwrap()[0]
1475 .parts
1476 .push(Part::text("added"));
1477 store.save_artifact_delta(&task, delta).await.unwrap();
1478
1479 assert_eq!(
1480 store.get(&TaskId::new("t")).await.unwrap(),
1481 Some(task),
1482 "wrong result after refusing {delta:?}"
1483 );
1484 }
1485 }
1486
1487 #[tokio::test]
1493 async fn delta_preserves_list_position() {
1494 let (store, _) = stores().await;
1495 let older = task_with("older", Some(vec![artifact("a", 1)]));
1496 store.save(&older).await.unwrap();
1497 let newer = task_with("newer", None);
1498 store.save(&newer).await.unwrap();
1499
1500 let before: Vec<_> = store
1501 .list(&ListTasksParams::default())
1502 .await
1503 .unwrap()
1504 .tasks
1505 .iter()
1506 .map(|t| t.id.clone())
1507 .collect();
1508
1509 let mut grown = older.clone();
1510 grown.artifacts.as_mut().unwrap()[0]
1511 .parts
1512 .push(Part::text("more"));
1513 store
1514 .save_artifact_delta(&grown, ArtifactDelta::AppendedParts { index: 0, count: 1 })
1515 .await
1516 .unwrap();
1517
1518 let after: Vec<_> = store
1519 .list(&ListTasksParams::default())
1520 .await
1521 .unwrap()
1522 .tasks
1523 .iter()
1524 .map(|t| t.id.clone())
1525 .collect();
1526
1527 assert_eq!(before, after, "appending an artifact reordered the list");
1528 }
1529}