Skip to main content

lean_ctx/core/a2a/
transfer.rs

1//! A2A-compatible envelope for handoff transfer bundles (GL#449).
2//!
3//! Wraps the proprietary `HandoffTransferBundleV1` in a spec-shaped A2A Task
4//! object so a foreign agent can consume a lean-ctx handoff with a plain A2A
5//! parser: `id` + `status` + one `data` artifact part carrying the bundle.
6//! Deterministic: every field derives from the bundle itself (no wall clock).
7
8use serde_json::Value;
9
10use crate::core::handoff_transfer_bundle::HandoffTransferBundleV1;
11
12/// Media type identifying the embedded bundle payload.
13pub const BUNDLE_MIME_V1: &str = "application/vnd.leanctx.handoff-bundle.v1+json";
14
15/// Wrap a transfer bundle in an A2A Task envelope.
16///
17/// The task id is derived from the embedded ledger's session id and content
18/// hash, so re-exporting the same handoff yields the same task id.
19pub fn wrap_bundle_as_a2a_task(bundle: &HandoffTransferBundleV1) -> Result<Value, String> {
20    let bundle_json =
21        serde_json::to_value(bundle).map_err(|e| format!("bundle serialization failed: {e}"))?;
22
23    let session_id = &bundle.ledger.session.id;
24    let task_id = format!(
25        "handoff-{session_id}-{}",
26        &bundle.ledger.content_md5[..bundle.ledger.content_md5.len().min(8)]
27    );
28
29    Ok(serde_json::json!({
30        "id": task_id,
31        "status": {
32            "state": "completed",
33            "timestamp": bundle.exported_at.to_rfc3339(),
34        },
35        "messages": [],
36        "artifacts": [{
37            "type": "data",
38            "mimeType": BUNDLE_MIME_V1,
39            "data": bundle_json,
40        }],
41        "history": [],
42        "metadata": {
43            "producer": "lean-ctx",
44            "bundleSchemaVersion": bundle.schema_version,
45            "privacy": bundle.privacy,
46        },
47    }))
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::core::handoff_ledger::HandoffLedgerV1;
54    use crate::core::handoff_transfer_bundle::{ArtifactsExcerptV1, ProjectIdentityV1};
55
56    fn sample_bundle() -> HandoffTransferBundleV1 {
57        let mut ledger = HandoffLedgerV1::default();
58        ledger.session.id = "sess-42".to_string();
59        ledger.content_md5 = "abcdef0123456789".to_string();
60        HandoffTransferBundleV1 {
61            schema_version: 1,
62            exported_at: chrono::DateTime::parse_from_rfc3339("2026-06-01T12:00:00Z")
63                .unwrap()
64                .with_timezone(&chrono::Utc),
65            privacy: "redacted".to_string(),
66            project: ProjectIdentityV1 {
67                project_root_hash: None,
68                project_identity_hash: None,
69            },
70            ledger,
71            artifacts: ArtifactsExcerptV1::default(),
72            signature: None,
73            signer_public_key: None,
74            signer_agent_id: None,
75        }
76    }
77
78    #[test]
79    fn wraps_bundle_with_required_a2a_task_fields() {
80        let task = wrap_bundle_as_a2a_task(&sample_bundle()).unwrap();
81        assert_eq!(task["id"], "handoff-sess-42-abcdef01");
82        assert_eq!(task["status"]["state"], "completed");
83        assert_eq!(task["artifacts"][0]["type"], "data");
84        assert_eq!(task["artifacts"][0]["mimeType"], BUNDLE_MIME_V1);
85        assert_eq!(task["artifacts"][0]["data"]["schema_version"], 1);
86        assert_eq!(task["metadata"]["producer"], "lean-ctx");
87    }
88
89    #[test]
90    fn wrap_is_deterministic() {
91        let a = wrap_bundle_as_a2a_task(&sample_bundle()).unwrap();
92        let b = wrap_bundle_as_a2a_task(&sample_bundle()).unwrap();
93        assert_eq!(a, b);
94    }
95}