use super::*;
use a2a_protocol_types::artifact::Artifact;
use a2a_protocol_types::message::Part;
use a2a_protocol_types::task::{ContextId, TaskState, TaskStatus};
async fn stores() -> (SqliteTaskStore, SqliteTaskStore) {
(
SqliteTaskStore::new("sqlite::memory:")
.await
.expect("delta"),
SqliteTaskStore::new("sqlite::memory:").await.expect("save"),
)
}
fn task_with(id: &str, artifacts: Option<Vec<Artifact>>) -> Task {
Task {
id: TaskId::new(id),
context_id: ContextId::new("ctx"),
status: TaskStatus::new(TaskState::Working),
history: None,
artifacts,
metadata: None,
}
}
fn artifact(id: &str, parts: usize) -> Artifact {
Artifact::new(
id,
(0..parts).map(|i| Part::text(format!("p{i}"))).collect(),
)
}
#[tokio::test]
async fn appending_matches_full_save_at_every_step() {
let (delta_store, save_store) = stores().await;
let mut task = task_with("t", Some(vec![artifact("a", 1)]));
delta_store.save(&task).await.unwrap();
save_store.save(&task).await.unwrap();
for i in 0..120 {
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text(format!("chunk{i}")));
delta_store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.unwrap();
save_store.save(&task).await.unwrap();
let id = TaskId::new("t");
assert_eq!(
delta_store.get(&id).await.unwrap(),
save_store.get(&id).await.unwrap(),
"diverged after {i} appends"
);
}
}
#[tokio::test]
async fn list_returns_the_same_parts_as_get_mid_stream() {
let store = SqliteTaskStore::new("sqlite::memory:")
.await
.expect("store");
let mut task = task_with("t-list", Some(vec![artifact("a", 1)]));
store.save(&task).await.unwrap();
for i in 0..5 {
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text(format!("chunk{i}")));
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.unwrap();
}
let from_get = store
.get(&TaskId::new("t-list"))
.await
.unwrap()
.expect("task exists");
let listed = store.list(&ListTasksParams::default()).await.unwrap();
let from_list = listed
.tasks
.iter()
.find(|t| t.id.0 == "t-list")
.expect("task is listed");
assert_eq!(
from_list, &from_get,
"list and get must agree about a task that is mid-stream"
);
assert_eq!(
from_list.artifacts.as_ref().unwrap()[0].parts.len(),
6,
"one seeded part plus five appended"
);
}
async fn journal_rows(store: &SqliteTaskStore) -> i64 {
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM task_artifact_appends")
.fetch_one(&store.pool)
.await
.expect("count");
n
}
#[tokio::test]
async fn a_full_save_empties_the_journal() {
let store = SqliteTaskStore::new("sqlite::memory:")
.await
.expect("store");
let mut task = task_with("t-bound", Some(vec![artifact("a", 1)]));
store.save(&task).await.unwrap();
for i in 0..10 {
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text(format!("chunk{i}")));
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.unwrap();
}
assert_eq!(
journal_rows(&store).await,
10,
"appends between saves are what the journal is for"
);
task.status = TaskStatus::new(TaskState::Completed);
store.save(&task).await.unwrap();
assert_eq!(
journal_rows(&store).await,
0,
"a full save writes every part into the document, so the rows it \
supersedes must go with it — otherwise the table grows forever"
);
}
#[tokio::test]
async fn deleting_a_task_discards_its_journalled_parts() {
let store = SqliteTaskStore::new("sqlite::memory:")
.await
.expect("store");
let mut task = task_with("t-reuse", Some(vec![artifact("a", 1)]));
store.save(&task).await.unwrap();
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("secret-from-the-first-task"));
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.unwrap();
store.delete(&TaskId::new("t-reuse")).await.unwrap();
let fresh = task_with("t-reuse", Some(vec![artifact("a", 1)]));
store.save(&fresh).await.unwrap();
let read = store
.get(&TaskId::new("t-reuse"))
.await
.unwrap()
.expect("task exists");
assert_eq!(
read, fresh,
"the replacement task must not inherit the deleted one's parts"
);
}
#[tokio::test]
async fn multi_part_append_preserves_order() {
let (store, _) = stores().await;
let mut task = task_with("t", Some(vec![artifact("a", 1)]));
store.save(&task).await.unwrap();
let added = vec![
Part::text("first"),
Part::text("second"),
Part::text("third"),
];
task.artifacts.as_mut().unwrap()[0]
.parts
.extend(added.clone());
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 3 })
.await
.unwrap();
assert_eq!(store.get(&TaskId::new("t")).await.unwrap(), Some(task));
}
#[tokio::test]
async fn pushing_matches_full_save_at_every_step() {
let (delta_store, save_store) = stores().await;
let mut task = task_with("t", Some(vec![]));
delta_store.save(&task).await.unwrap();
save_store.save(&task).await.unwrap();
for i in 0..60 {
task.artifacts
.as_mut()
.unwrap()
.push(artifact(&format!("a{i}"), 2));
let index = task.artifacts.as_ref().unwrap().len() - 1;
delta_store
.save_artifact_delta(&task, ArtifactDelta::Pushed { index })
.await
.unwrap();
save_store.save(&task).await.unwrap();
let id = TaskId::new("t");
assert_eq!(
delta_store.get(&id).await.unwrap(),
save_store.get(&id).await.unwrap(),
"diverged after {i} pushes"
);
}
}
#[tokio::test]
async fn absent_row_falls_back_to_full_save() {
let (store, _) = stores().await;
let task = task_with("never-saved", Some(vec![artifact("a", 3)]));
store
.save_artifact_delta(&task, ArtifactDelta::Pushed { index: 0 })
.await
.unwrap();
assert_eq!(
store.get(&TaskId::new("never-saved")).await.unwrap(),
Some(task)
);
}
#[tokio::test]
async fn stored_task_without_artifacts_falls_back() {
let (store, _) = stores().await;
let mut task = task_with("t", None);
store.save(&task).await.unwrap();
task.artifacts = Some(vec![artifact("a", 2)]);
store
.save_artifact_delta(&task, ArtifactDelta::Pushed { index: 0 })
.await
.unwrap();
assert_eq!(store.get(&TaskId::new("t")).await.unwrap(), Some(task));
}
#[tokio::test]
async fn inconsistent_deltas_fall_back_and_stay_correct() {
for delta in [
ArtifactDelta::AppendedParts { index: 9, count: 1 }, ArtifactDelta::AppendedParts {
index: 0,
count: 99,
}, ArtifactDelta::AppendedParts { index: 0, count: 0 }, ArtifactDelta::Pushed { index: 7 }, ] {
let (store, _) = stores().await;
let mut task = task_with("t", Some(vec![artifact("a", 1)]));
store.save(&task).await.unwrap();
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("added"));
store.save_artifact_delta(&task, delta).await.unwrap();
assert_eq!(
store.get(&TaskId::new("t")).await.unwrap(),
Some(task),
"wrong result after refusing {delta:?}"
);
}
}
#[tokio::test]
async fn delta_preserves_list_position() {
let (store, _) = stores().await;
let older = task_with("older", Some(vec![artifact("a", 1)]));
store.save(&older).await.unwrap();
let newer = task_with("newer", None);
store.save(&newer).await.unwrap();
let before: Vec<_> = store
.list(&ListTasksParams::default())
.await
.unwrap()
.tasks
.iter()
.map(|t| t.id.clone())
.collect();
let mut grown = older.clone();
grown.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("more"));
store
.save_artifact_delta(&grown, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.unwrap();
let after: Vec<_> = store
.list(&ListTasksParams::default())
.await
.unwrap()
.tasks
.iter()
.map(|t| t.id.clone())
.collect();
assert_eq!(before, after, "appending an artifact reordered the list");
}
#[tokio::test]
async fn a_migrated_store_can_take_an_artifact_append() {
let dir = std::env::temp_dir().join(format!("a2a-migrated-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let url = format!("sqlite://{}/m.db?mode=rwc", dir.display());
let store = SqliteTaskStore::with_migrations(&url)
.await
.expect("migrated store");
let mut task = task_with("t-migrated", Some(vec![artifact("a", 1)]));
store.save(&task).await.expect("seed");
task.artifacts.as_mut().unwrap()[0]
.parts
.push(Part::text("appended"));
store
.save_artifact_delta(&task, ArtifactDelta::AppendedParts { index: 0, count: 1 })
.await
.expect("a migrated schema must carry the journal table");
let read = store
.get(&TaskId::new("t-migrated"))
.await
.expect("read")
.expect("task exists");
assert_eq!(
read.artifacts.as_ref().unwrap()[0].parts.len(),
2,
"the appended part must come back"
);
let _ = std::fs::remove_dir_all(&dir);
}