Skip to main content

systemprompt_agent/services/a2a_server/streaming/
webhook_client.rs

1//! Webhook delivery for AGUI and A2A streaming events.
2//!
3//! The free [`broadcast_agui_event`] / [`broadcast_a2a_event`] entry points
4//! dispatch through a globally installed [`WebhookBroadcaster`]. Production
5//! installs nothing and gets the default [`HttpWebhookBroadcaster`]; tests
6//! call [`install_for_test`] with a recording fake. The indirection lets
7//! the deep callers in `event_loop`, `complete_handler`, `message_handler`,
8//! and `skills` stay as free-function calls while the harness still swaps
9//! the network for a deterministic spy.
10
11use std::sync::{Arc, OnceLock};
12
13use async_trait::async_trait;
14use reqwest::Client;
15use serde::Serialize;
16use systemprompt_identifiers::UserId;
17use systemprompt_models::{A2AEvent, AgUiEvent, Config};
18
19#[derive(Debug, thiserror::Error)]
20pub enum WebhookError {
21    #[error("HTTP request failed: {0}")]
22    Request(#[from] reqwest::Error),
23    #[error("Webhook returned error status {status}: {message}")]
24    StatusError { status: u16, message: String },
25}
26
27#[async_trait]
28pub trait WebhookBroadcaster: Send + Sync + std::fmt::Debug {
29    async fn broadcast_agui(
30        &self,
31        user_id: &UserId,
32        event: AgUiEvent,
33        auth_token: &str,
34    ) -> Result<usize, WebhookError>;
35
36    async fn broadcast_a2a(
37        &self,
38        user_id: &UserId,
39        event: A2AEvent,
40        auth_token: &str,
41    ) -> Result<usize, WebhookError>;
42}
43
44#[derive(Serialize)]
45struct AgUiWebhookPayload {
46    #[serde(flatten)]
47    event: AgUiEvent,
48    user_id: UserId,
49}
50
51#[derive(Serialize)]
52struct A2AWebhookPayload {
53    #[serde(flatten)]
54    event: A2AEvent,
55    user_id: UserId,
56}
57
58fn get_api_url() -> String {
59    Config::get().map_or_else(
60        |_| "http://localhost:3000".to_owned(),
61        |c| c.api_internal_url.clone(),
62    )
63}
64
65#[derive(Debug, Default, Clone, Copy)]
66pub struct HttpWebhookBroadcaster;
67
68#[async_trait]
69impl WebhookBroadcaster for HttpWebhookBroadcaster {
70    async fn broadcast_agui(
71        &self,
72        user_id: &UserId,
73        event: AgUiEvent,
74        auth_token: &str,
75    ) -> Result<usize, WebhookError> {
76        let url = format!("{}/api/v1/webhook/agui", get_api_url());
77        let event_type = event.event_type();
78        if auth_token.is_empty() {
79            tracing::warn!(
80                event_type = ?event_type,
81                user_id = %user_id,
82                "AGUI broadcast with empty auth_token"
83            );
84        }
85        let payload = AgUiWebhookPayload {
86            event,
87            user_id: user_id.clone(),
88        };
89        post_and_decode(&url, auth_token, &payload, "AGUI").await
90    }
91
92    async fn broadcast_a2a(
93        &self,
94        user_id: &UserId,
95        event: A2AEvent,
96        auth_token: &str,
97    ) -> Result<usize, WebhookError> {
98        let url = format!("{}/api/v1/webhook/a2a", get_api_url());
99        let payload = A2AWebhookPayload {
100            event,
101            user_id: user_id.clone(),
102        };
103        post_and_decode(&url, auth_token, &payload, "A2A").await
104    }
105}
106
107#[derive(serde::Deserialize)]
108struct WebhookResponse {
109    connection_count: usize,
110}
111
112async fn post_and_decode<T: Serialize + Sync + ?Sized>(
113    url: &str,
114    auth_token: &str,
115    payload: &T,
116    kind: &str,
117) -> Result<usize, WebhookError> {
118    let client = Client::new();
119    let response = client
120        .post(url)
121        .header("Authorization", format!("Bearer {auth_token}"))
122        .header("Content-Type", "application/json")
123        .json(payload)
124        .send()
125        .await;
126    match response {
127        Ok(resp) if resp.status().is_success() => match resp.json::<WebhookResponse>().await {
128            Ok(r) => {
129                tracing::debug!(
130                    kind = kind,
131                    connection_count = r.connection_count,
132                    "broadcasted"
133                );
134                Ok(r.connection_count)
135            },
136            Err(e) => {
137                tracing::error!(kind = kind, error = %e, "response parse error");
138                Err(WebhookError::Request(e))
139            },
140        },
141        Ok(resp) => {
142            let status = resp.status().as_u16();
143            let message = resp
144                .text()
145                .await
146                .unwrap_or_else(|e| format!("<error reading response: {e}>"));
147            tracing::error!(kind = kind, status, message = %message, "event failed");
148            Err(WebhookError::StatusError { status, message })
149        },
150        Err(e) => {
151            tracing::error!(kind = kind, error = %e, "request error");
152            Err(WebhookError::Request(e))
153        },
154    }
155}
156
157static GLOBAL_BROADCASTER: OnceLock<Arc<dyn WebhookBroadcaster>> = OnceLock::new();
158
159fn default_broadcaster() -> Arc<dyn WebhookBroadcaster> {
160    Arc::new(HttpWebhookBroadcaster)
161}
162
163fn active_broadcaster() -> Arc<dyn WebhookBroadcaster> {
164    Arc::clone(GLOBAL_BROADCASTER.get_or_init(default_broadcaster))
165}
166
167/// Test-only seam.
168///
169/// Installs `broadcaster` as the process-wide implementation returned by every
170/// subsequent `broadcast_*` call. Subsequent calls to `install_for_test` are
171/// no-ops — the first caller wins (matching the
172/// `RsaSigningKey::install_for_test` / `Config::install` pattern).
173pub fn install_for_test(broadcaster: Arc<dyn WebhookBroadcaster>) {
174    drop(GLOBAL_BROADCASTER.set(broadcaster));
175}
176
177pub async fn broadcast_agui_event(
178    user_id: &UserId,
179    event: AgUiEvent,
180    auth_token: &str,
181) -> Result<usize, WebhookError> {
182    active_broadcaster()
183        .broadcast_agui(user_id, event, auth_token)
184        .await
185}
186
187pub async fn broadcast_a2a_event(
188    user_id: &UserId,
189    event: A2AEvent,
190    auth_token: &str,
191) -> Result<usize, WebhookError> {
192    active_broadcaster()
193        .broadcast_a2a(user_id, event, auth_token)
194        .await
195}
196
197#[derive(Clone, Debug)]
198pub struct WebhookContext {
199    user_id: UserId,
200    auth_token: String,
201}
202
203impl WebhookContext {
204    pub fn new(user_id: UserId, auth_token: impl Into<String>) -> Self {
205        Self {
206            user_id,
207            auth_token: auth_token.into(),
208        }
209    }
210
211    pub const fn user_id(&self) -> &UserId {
212        &self.user_id
213    }
214
215    pub async fn broadcast_agui(&self, event: AgUiEvent) -> Result<usize, WebhookError> {
216        broadcast_agui_event(&self.user_id, event, &self.auth_token).await
217    }
218
219    pub async fn broadcast_a2a(&self, event: A2AEvent) -> Result<usize, WebhookError> {
220        broadcast_a2a_event(&self.user_id, event, &self.auth_token).await
221    }
222}