1use std::pin::Pin;
54use std::time::Duration;
55
56#[cfg(feature = "sse-server")]
57use std::sync::Arc;
58
59use futures_util::{Stream, StreamExt};
60use serde::{Deserialize, Serialize};
61use tokio::sync::mpsc;
62use tokio_stream::wrappers::ReceiverStream;
63
64use super::state::{AgentStreamEvent, ToolCallState};
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum SseFrame {
69 Event {
72 id: Option<u64>,
74 retry: Option<Duration>,
76 event: &'static str,
78 data: String,
80 },
81 Comment(String),
83}
84
85impl SseFrame {
86 pub fn event(id: u64, event: &'static str, data: String) -> Self {
88 SseFrame::Event {
89 id: Some(id),
90 retry: None,
91 event,
92 data,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct SseOptions {
100 pub resume_from: Option<u64>,
102 pub heartbeat: Option<Duration>,
105 pub retry: Option<Duration>,
107}
108
109impl SseOptions {
110 pub fn with_resume_from(mut self, last_event_id: u64) -> Self {
112 self.resume_from = Some(last_event_id);
113 self
114 }
115
116 pub fn with_heartbeat(mut self, interval: Duration) -> Self {
118 self.heartbeat = Some(interval);
119 self
120 }
121
122 pub fn with_retry(mut self, retry: Duration) -> Self {
124 self.retry = Some(retry);
125 self
126 }
127}
128
129pub fn sse_event_name(event: &AgentStreamEvent) -> &'static str {
131 match event {
132 AgentStreamEvent::Text { .. } => "text",
133 AgentStreamEvent::ToolCall { .. } => "tool_call",
134 AgentStreamEvent::ToolStart { .. } => "tool_start",
135 AgentStreamEvent::ToolEnd { .. } => "tool_end",
136 AgentStreamEvent::PipelineStep { .. } => "pipeline_step",
137 AgentStreamEvent::FinalAnswer { .. } => "final_answer",
138 AgentStreamEvent::Error { .. } => "error",
139 }
140}
141
142pub fn sse_event_payload(event: &AgentStreamEvent) -> serde_json::Value {
144 match event {
145 AgentStreamEvent::Text { content } => serde_json::json!({ "content": content }),
146 AgentStreamEvent::ToolStart { name, input } => {
147 serde_json::json!({ "name": name, "input": input })
148 }
149 AgentStreamEvent::ToolEnd { name, output } => {
150 serde_json::json!({ "name": name, "output": output })
151 }
152 AgentStreamEvent::PipelineStep { step, detail } => {
153 serde_json::json!({ "step": step, "detail": detail })
154 }
155 AgentStreamEvent::FinalAnswer { content } => {
156 serde_json::json!({ "content": content })
157 }
158 AgentStreamEvent::Error { message } => {
159 serde_json::json!({ "message": message })
160 }
161 AgentStreamEvent::ToolCall { state } => tool_call_payload(state),
162 }
163}
164
165fn tool_call_payload(state: &ToolCallState) -> serde_json::Value {
166 let state_key: Option<(&'static str, serde_json::Value)> = match state {
168 ToolCallState::Started { .. } | ToolCallState::Executing { .. } => None,
169 ToolCallState::ArgumentsStreaming { partial_args, .. } => {
170 Some(("partial_args", partial_args.clone().into()))
171 }
172 ToolCallState::ArgumentsComplete { args, .. } => Some(("args", args.clone())),
173 ToolCallState::Completed { result, .. } => Some(("result", result.clone().into())),
174 ToolCallState::Failed { error, .. } => Some(("error", error.clone().into())),
175 };
176 let state_name = match state {
177 ToolCallState::Started { .. } => "started",
178 ToolCallState::ArgumentsStreaming { .. } => "arguments_streaming",
179 ToolCallState::ArgumentsComplete { .. } => "arguments_complete",
180 ToolCallState::Executing { .. } => "executing",
181 ToolCallState::Completed { .. } => "completed",
182 ToolCallState::Failed { .. } => "failed",
183 };
184 let mut payload = serde_json::json!({
185 "state": state_name,
186 "tool_name": state.tool_name(),
187 "call_id": state.call_id(),
188 });
189 if let (Some((key, value)), Some(obj)) = (state_key, payload.as_object_mut()) {
190 obj.insert(key.to_string(), value);
191 }
192 payload
193}
194
195pub fn encode_sse_frame(frame: &SseFrame) -> String {
201 match frame {
202 SseFrame::Comment(text) => format!(": {text}\n\n"),
203 SseFrame::Event {
204 id,
205 retry,
206 event,
207 data,
208 } => {
209 let mut rendered = String::new();
210 if let Some(retry) = retry {
211 rendered.push_str(&format!("retry: {}\n", retry.as_millis()));
212 }
213 if let Some(id) = id {
214 rendered.push_str(&format!("id: {id}\n"));
215 }
216 rendered.push_str(&format!("event: {event}\n"));
217 for line in data.split('\n') {
221 rendered.push_str(&format!("data: {line}\n"));
222 }
223 rendered.push('\n');
224 rendered
225 }
226 }
227}
228
229pub type AgentEventStream = Pin<Box<dyn Stream<Item = AgentStreamEvent> + Send>>;
232
233pub fn agent_sse_frames<S>(events: S, options: SseOptions) -> ReceiverStream<SseFrame>
240where
241 S: Stream<Item = AgentStreamEvent> + Send + 'static,
242{
243 let (tx, rx) = mpsc::channel(16);
244 tokio::spawn(async move {
245 let mut events = Box::pin(events);
246 let resume_from = options.resume_from.unwrap_or(0);
247 let mut next_id: u64 = 1;
248 let mut retry_hint = options.retry;
249
250 let mut ticker = match options.heartbeat {
253 Some(interval) => {
254 let mut interval = tokio::time::interval(interval);
255 interval.tick().await;
256 Some(interval)
257 }
258 None => None,
259 };
260
261 loop {
262 let next = if let Some(ticker) = ticker.as_mut() {
263 tokio::select! {
264 event = events.next() => event,
265 _ = ticker.tick() => {
266 if tx
267 .send(SseFrame::Comment("keep-alive".to_string()))
268 .await
269 .is_err()
270 {
271 return;
272 }
273 continue;
274 }
275 }
276 } else {
277 events.next().await
278 };
279
280 let Some(event) = next else { break };
281
282 let id = next_id;
283 next_id += 1;
284 if id <= resume_from {
285 continue;
286 }
287 let payload = serde_json::to_string(&sse_event_payload(&event)).unwrap_or_else(|_| {
288 "{\"message\":\"event payload serialization failed\"}".to_string()
289 });
290 let mut frame = SseFrame::event(id, sse_event_name(&event), payload);
291 if let SseFrame::Event { retry, .. } = &mut frame {
292 *retry = retry_hint.take();
293 }
294 if tx.send(frame).await.is_err() {
295 return;
298 }
299 }
300
301 let _ = tx
304 .send(SseFrame::Event {
305 id: None,
306 retry: None,
307 event: "done",
308 data: "{\"status\":\"done\"}".to_string(),
309 })
310 .await;
311 });
312
313 ReceiverStream::new(rx)
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct AgentSseRequest {
319 pub input: String,
321 #[serde(default)]
324 pub last_event_id: Option<u64>,
325}
326
327#[cfg(feature = "sse-server")]
330#[derive(Debug, Clone, Deserialize)]
331pub struct AgentSseQuery {
332 pub input: String,
334 #[serde(default)]
336 pub last_event_id: Option<u64>,
337}
338
339#[cfg(feature = "sse-server")]
342pub const DEFAULT_SSE_HEARTBEAT: Duration = Duration::from_secs(20);
343
344#[cfg(feature = "sse-server")]
346#[derive(Debug, Clone, Default)]
347pub struct AgentSseServerConfig {
348 pub heartbeat: Option<Duration>,
352 pub retry: Option<Duration>,
354}
355
356#[cfg(feature = "sse-server")]
357impl AgentSseServerConfig {
358 pub fn with_heartbeat(mut self, interval: Duration) -> Self {
360 self.heartbeat = Some(interval);
361 self
362 }
363
364 pub fn without_heartbeat(mut self) -> Self {
366 self.heartbeat = Some(Duration::ZERO);
367 self
368 }
369
370 pub fn with_retry(mut self, retry: Duration) -> Self {
372 self.retry = Some(retry);
373 self
374 }
375}
376
377#[cfg(feature = "sse-server")]
379pub type AgentStreamFuture = Pin<Box<dyn std::future::Future<Output = AgentEventStream> + Send>>;
380
381#[cfg(feature = "sse-server")]
394pub trait AgentStreamFactory: Send + Sync {
395 fn start(&self, input: String) -> AgentStreamFuture;
397}
398
399#[cfg(feature = "sse-server")]
400impl<F, Fut> AgentStreamFactory for F
401where
402 F: Fn(String) -> Fut + Send + Sync + 'static,
403 Fut: std::future::Future<Output = AgentEventStream> + Send + 'static,
404{
405 fn start(&self, input: String) -> AgentStreamFuture {
406 Box::pin(self(input))
407 }
408}
409
410#[cfg(feature = "sse-server")]
412#[derive(Clone)]
413pub struct AgentSseState {
414 factory: Arc<dyn AgentStreamFactory>,
415 config: AgentSseServerConfig,
416}
417
418#[cfg(feature = "sse-server")]
420type AgentSseBody = axum::response::sse::Sse<
421 Pin<
422 Box<dyn Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>> + Send>,
423 >,
424>;
425
426#[cfg(feature = "sse-server")]
427fn build_options(
428 headers: &axum::http::HeaderMap,
429 body_last_event_id: Option<u64>,
430 config: &AgentSseServerConfig,
431) -> SseOptions {
432 let last_event_id = headers
433 .get("last-event-id")
434 .and_then(|value| value.to_str().ok())
435 .and_then(|value| value.trim().parse::<u64>().ok())
436 .or(body_last_event_id);
437
438 let mut options = SseOptions::default();
439 if let Some(id) = last_event_id {
440 options = options.with_resume_from(id);
441 }
442 match config.heartbeat {
444 Some(interval) if interval > Duration::ZERO => options.heartbeat = Some(interval),
445 Some(_) => {}
446 None => options.heartbeat = Some(DEFAULT_SSE_HEARTBEAT),
447 }
448 options.retry = config.retry;
449 options
450}
451
452#[cfg(feature = "sse-server")]
453async fn sse_response(
454 state: AgentSseState,
455 headers: axum::http::HeaderMap,
456 input: String,
457 body_last_event_id: Option<u64>,
458) -> Result<AgentSseBody, (axum::http::StatusCode, String)> {
459 use std::convert::Infallible;
460
461 if input.trim().is_empty() {
462 return Err((
463 axum::http::StatusCode::BAD_REQUEST,
464 "input must not be empty".to_string(),
465 ));
466 }
467
468 let options = build_options(&headers, body_last_event_id, &state.config);
469 let events = state.factory.start(input).await;
470 let frames = agent_sse_frames(events, options);
471 let body: Pin<Box<dyn Stream<Item = Result<_, Infallible>> + Send>> =
472 Box::pin(frames.map(|frame| {
473 let event = match frame {
476 SseFrame::Comment(text) => axum::response::sse::Event::default().comment(text),
480 SseFrame::Event {
481 id,
482 retry,
483 event,
484 data,
485 } => {
486 let mut builder = axum::response::sse::Event::default();
490 if let Some(retry) = retry {
491 builder = builder.retry(retry);
492 }
493 if let Some(id) = id {
494 builder = builder.id(id.to_string());
495 }
496 builder.event(event).data(data)
497 }
498 };
499 Ok(event)
500 }));
501 Ok(axum::response::sse::Sse::new(body))
502}
503
504#[cfg(feature = "sse-server")]
506pub async fn agent_sse_handler(
507 axum::extract::State(state): axum::extract::State<AgentSseState>,
508 headers: axum::http::HeaderMap,
509 axum::Json(request): axum::Json<AgentSseRequest>,
510) -> Result<AgentSseBody, (axum::http::StatusCode, String)> {
511 sse_response(state, headers, request.input, request.last_event_id).await
512}
513
514#[cfg(feature = "sse-server")]
517pub async fn agent_sse_get_handler(
518 axum::extract::State(state): axum::extract::State<AgentSseState>,
519 headers: axum::http::HeaderMap,
520 axum::extract::Query(query): axum::extract::Query<AgentSseQuery>,
521) -> Result<AgentSseBody, (axum::http::StatusCode, String)> {
522 sse_response(state, headers, query.input, query.last_event_id).await
523}
524
525#[cfg(feature = "sse-server")]
529fn cors_layer() -> tower_http::cors::CorsLayer {
530 use axum::http::{HeaderValue, Method};
531 use tower_http::cors::{AllowOrigin, Any, CorsLayer};
532
533 CorsLayer::new()
534 .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _| {
535 origin
536 .to_str()
537 .map(|s| s.starts_with("http://localhost") || s.starts_with("http://127.0.0.1"))
538 .unwrap_or(false)
539 }))
540 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
541 .allow_headers(Any)
542}
543
544#[cfg(feature = "sse-server")]
550pub async fn serve_agent_sse(
551 factory: Arc<dyn AgentStreamFactory>,
552 port: u16,
553) -> std::io::Result<()> {
554 let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
555 let listener = tokio::net::TcpListener::bind(addr).await?;
556 serve_agent_sse_on(factory, listener).await
557}
558
559#[cfg(feature = "sse-server")]
561pub async fn serve_agent_sse_on(
562 factory: Arc<dyn AgentStreamFactory>,
563 listener: tokio::net::TcpListener,
564) -> std::io::Result<()> {
565 axum::serve(listener, agent_sse_router(factory)).await
566}
567
568#[cfg(feature = "sse-server")]
571pub fn agent_sse_router(factory: Arc<dyn AgentStreamFactory>) -> axum::Router {
572 agent_sse_router_with(factory, AgentSseServerConfig::default())
573}
574
575#[cfg(feature = "sse-server")]
577pub fn agent_sse_router_with(
578 factory: Arc<dyn AgentStreamFactory>,
579 config: AgentSseServerConfig,
580) -> axum::Router {
581 use axum::routing::post;
582 let state = AgentSseState { factory, config };
583 axum::Router::new()
584 .route(
585 "/agent/stream",
586 post(agent_sse_handler).get(agent_sse_get_handler),
587 )
588 .layer(cors_layer())
589 .with_state(state)
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use futures_util::stream;
596
597 fn sample_events() -> Vec<AgentStreamEvent> {
598 vec![
599 AgentStreamEvent::Text {
600 content: "hi".to_string(),
601 },
602 AgentStreamEvent::ToolStart {
603 name: "calc".to_string(),
604 input: "1+1".to_string(),
605 },
606 AgentStreamEvent::ToolCall {
607 state: ToolCallState::Completed {
608 tool_name: "calc".to_string(),
609 call_id: "c1".to_string(),
610 result: "2".to_string(),
611 },
612 },
613 AgentStreamEvent::PipelineStep {
614 step: "generating".to_string(),
615 detail: None,
616 },
617 AgentStreamEvent::FinalAnswer {
618 content: "答案".to_string(),
619 },
620 ]
621 }
622
623 #[tokio::test]
624 async fn frames_are_numbered_named_and_terminated_by_done() {
625 let frames: Vec<SseFrame> =
626 agent_sse_frames(stream::iter(sample_events()), SseOptions::default())
627 .collect()
628 .await;
629
630 assert_eq!(frames.len(), 6);
632 for (index, frame) in frames.iter().take(5).enumerate() {
633 match frame {
634 SseFrame::Event {
635 id: Some(id),
636 retry: None,
637 event: _,
638 data: _,
639 } => assert_eq!(*id, index as u64 + 1),
640 other => panic!("expected numbered event, got {other:?}"),
641 }
642 }
643 assert_eq!(
644 frames[5],
645 SseFrame::Event {
646 id: None,
647 retry: None,
648 event: "done",
649 data: "{\"status\":\"done\"}".to_string(),
650 }
651 );
652 }
653
654 #[test]
655 fn text_frame_wire_snapshot() {
656 let frame = SseFrame::event(
657 7,
658 "text",
659 serde_json::to_string(&serde_json::json!({"content": "hello"})).unwrap(),
660 );
661 assert_eq!(
662 encode_sse_frame(&frame),
663 "id: 7\nevent: text\ndata: {\"content\":\"hello\"}\n\n"
664 );
665 }
666
667 #[test]
668 fn retry_hint_is_rendered_in_milliseconds() {
669 let frame = SseFrame::Event {
670 id: Some(1),
671 retry: Some(Duration::from_millis(2500)),
672 event: "text",
673 data: "{}".to_string(),
674 };
675 assert_eq!(
676 encode_sse_frame(&frame),
677 "retry: 2500\nid: 1\nevent: text\ndata: {}\n\n"
678 );
679 }
680
681 #[test]
682 fn comment_frame_wire_snapshot() {
683 assert_eq!(
684 encode_sse_frame(&SseFrame::Comment("keep-alive".into())),
685 ": keep-alive\n\n"
686 );
687 }
688
689 #[test]
690 fn multiline_data_gets_one_data_prefix_per_line() {
691 let frame = SseFrame::event(1, "text", "line1\nline2".to_string());
692 assert_eq!(
693 encode_sse_frame(&frame),
694 "id: 1\nevent: text\ndata: line1\ndata: line2\n\n"
695 );
696 }
697
698 #[test]
699 fn event_names_and_payload_shapes_cover_all_variants() {
700 assert_eq!(
701 sse_event_name(&AgentStreamEvent::Text {
702 content: "x".into()
703 }),
704 "text"
705 );
706 let payload = sse_event_payload(&AgentStreamEvent::ToolCall {
707 state: ToolCallState::ArgumentsStreaming {
708 tool_name: "search".into(),
709 call_id: "c9".into(),
710 partial_args: "{\"q\"".into(),
711 },
712 });
713 assert_eq!(payload["state"], "arguments_streaming");
714 assert_eq!(payload["tool_name"], "search");
715 assert_eq!(payload["call_id"], "c9");
716 assert_eq!(payload["partial_args"], "{\"q\"");
717
718 let started = sse_event_payload(&AgentStreamEvent::ToolCall {
719 state: ToolCallState::Started {
720 tool_name: "t".into(),
721 call_id: "c".into(),
722 },
723 });
724 assert_eq!(started["state"], "started");
725 assert!(started.get("partial_args").is_none());
726
727 let failed = sse_event_payload(&AgentStreamEvent::ToolCall {
728 state: ToolCallState::Failed {
729 tool_name: "t".into(),
730 call_id: "c".into(),
731 error: "boom".into(),
732 },
733 });
734 assert_eq!(failed["state"], "failed");
735 assert_eq!(failed["error"], "boom");
736
737 assert_eq!(
738 sse_event_payload(&AgentStreamEvent::ToolEnd {
739 name: "x".into(),
740 output: "y".into()
741 })["output"],
742 "y"
743 );
744 assert_eq!(
745 sse_event_payload(&AgentStreamEvent::Error {
746 message: "bad".into()
747 })["message"],
748 "bad"
749 );
750 }
751
752 #[tokio::test]
753 async fn resume_from_suppresses_frames_at_or_below_id() {
754 let frames: Vec<SseFrame> = agent_sse_frames(
755 stream::iter(sample_events()),
756 SseOptions::default().with_resume_from(3),
757 )
758 .collect()
759 .await;
760 let ids: Vec<u64> = frames
761 .iter()
762 .filter_map(|f| match f {
763 SseFrame::Event { id: Some(id), .. } => Some(*id),
764 _ => None,
765 })
766 .collect();
767 assert_eq!(ids, vec![4, 5]);
768 }
769
770 #[tokio::test]
771 async fn retry_is_attached_to_the_first_frame_only() {
772 let frames: Vec<SseFrame> = agent_sse_frames(
773 stream::iter(sample_events()),
774 SseOptions::default().with_retry(Duration::from_secs(3)),
775 )
776 .collect()
777 .await;
778 let retries: Vec<Option<Duration>> = frames
779 .iter()
780 .map(|f| match f {
781 SseFrame::Event { retry, .. } => *retry,
782 SseFrame::Comment(_) => None,
783 })
784 .collect();
785 assert_eq!(retries[0], Some(Duration::from_secs(3)));
786 assert!(retries[1..].iter().all(|slot| slot.is_none()));
787 }
788
789 #[tokio::test]
790 async fn heartbeat_emits_comments_while_idle() {
791 let frames = agent_sse_frames(
796 stream::pending::<AgentStreamEvent>(),
797 SseOptions::default().with_heartbeat(Duration::from_millis(20)),
798 );
799 let mut frames = Box::pin(frames);
800
801 for expected in 1..=2u64 {
802 let frame = tokio::time::timeout(Duration::from_secs(2), frames.next())
803 .await
804 .expect("heartbeat should arrive")
805 .expect("stream should stay open");
806 assert_eq!(
807 frame,
808 SseFrame::Comment("keep-alive".into()),
809 "tick {expected}"
810 );
811 }
812 }
813
814 #[tokio::test]
815 async fn dropping_the_frame_stream_cancels_the_producer() {
816 let (tx, rx) = mpsc::channel::<AgentStreamEvent>(4);
817 let mut frames = agent_sse_frames(ReceiverStream::new(rx), SseOptions::default());
818
819 tx.send(AgentStreamEvent::Text {
821 content: "first".into(),
822 })
823 .await
824 .unwrap();
825 let first = frames.next().await.expect("frame");
826 assert!(matches!(first, SseFrame::Event { id: Some(1), .. }));
827
828 drop(frames);
830
831 let closed = tokio::time::timeout(Duration::from_secs(5), async {
835 let mut failures = 0;
836 for i in 0..32 {
837 if tx
838 .send(AgentStreamEvent::Text {
839 content: format!("{i}"),
840 })
841 .await
842 .is_err()
843 {
844 failures += 1;
845 }
846 }
847 failures
848 })
849 .await
850 .expect("producer should observe disconnect promptly");
851 assert!(closed > 0, "channel must close after consumer drop");
852 }
853
854 #[tokio::test]
855 async fn error_event_still_precedes_done() {
856 let frames: Vec<SseFrame> = agent_sse_frames(
857 stream::iter(vec![AgentStreamEvent::Error {
858 message: "boom".into(),
859 }]),
860 SseOptions::default(),
861 )
862 .collect()
863 .await;
864 assert_eq!(frames.len(), 2);
865 assert!(matches!(
866 frames[0],
867 SseFrame::Event {
868 id: Some(1),
869 event: "error",
870 ..
871 }
872 ));
873 assert!(matches!(
874 frames[1],
875 SseFrame::Event {
876 id: None,
877 event: "done",
878 ..
879 }
880 ));
881 }
882}
883
884#[cfg(all(test, feature = "sse-server"))]
885mod axum_tests {
886 use super::*;
887 use axum::body::to_bytes;
888 use axum::extract::Request;
889 use axum::Router;
890 use futures_util::stream;
891 use tower::ServiceExt;
892
893 fn stub_factory(events: Vec<AgentStreamEvent>) -> Arc<dyn AgentStreamFactory> {
894 Arc::new(move |_input: String| {
895 let events = events.clone();
896 async move {
897 let stream: AgentEventStream = Box::pin(stream::iter(events));
898 stream
899 }
900 })
901 }
902
903 async fn send_request(
904 router: Router,
905 method: &str,
906 uri: &str,
907 body: Option<serde_json::Value>,
908 header: Option<&str>,
909 ) -> axum::response::Response {
910 let mut builder = Request::builder().method(method).uri(uri);
911 if body.is_some() {
912 builder = builder.header("content-type", "application/json");
913 }
914 if let Some(value) = header {
915 builder = builder.header("last-event-id", value);
916 }
917 let body = match body {
918 Some(value) => axum::body::Body::from(value.to_string()),
919 None => axum::body::Body::empty(),
920 };
921 router.oneshot(builder.body(body).unwrap()).await.unwrap()
922 }
923
924 async fn body_text(response: axum::response::Response) -> String {
925 assert_eq!(response.headers()["content-type"], "text/event-stream");
926 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
927 String::from_utf8(bytes.to_vec()).unwrap()
928 }
929
930 #[tokio::test]
931 async fn post_endpoint_streams_numbered_frames_for_posted_input() {
932 let events = vec![
933 AgentStreamEvent::Text {
934 content: "hello".into(),
935 },
936 AgentStreamEvent::FinalAnswer {
937 content: "hello".into(),
938 },
939 ];
940 let router = agent_sse_router(stub_factory(events));
941 let response = send_request(
942 router,
943 "POST",
944 "/agent/stream",
945 Some(serde_json::json!({"input": "hi"})),
946 None,
947 )
948 .await;
949 let body = body_text(response).await;
950 assert!(body.contains("id: 1\nevent: text\ndata: {\"content\":\"hello\"}"));
951 assert!(body.contains("event: final_answer"));
952 assert!(body.ends_with("event: done\ndata: {\"status\":\"done\"}\n\n"));
953 }
954
955 #[tokio::test]
956 async fn get_endpoint_streams_frames_for_native_eventsource_clients() {
957 let events = vec![AgentStreamEvent::Text {
958 content: "ping".into(),
959 }];
960 let router = agent_sse_router(stub_factory(events));
961 let response = send_request(
962 router,
963 "GET",
964 "/agent/stream?input=hello%20world",
965 None,
966 None,
967 )
968 .await;
969 let body = body_text(response).await;
970 assert!(body.contains("event: text"));
971 assert!(body.contains("{\"content\":\"ping\"}"));
972 }
973
974 #[tokio::test]
975 async fn last_event_id_header_suppresses_earlier_frames() {
976 let events = vec![
977 AgentStreamEvent::Text {
978 content: "one".into(),
979 },
980 AgentStreamEvent::Text {
981 content: "two".into(),
982 },
983 ];
984 let router = agent_sse_router(stub_factory(events));
985 let response = send_request(
986 router,
987 "POST",
988 "/agent/stream",
989 Some(serde_json::json!({"input": "hi", "last_event_id": 99})),
990 Some("1"),
991 )
992 .await;
993 let body = body_text(response).await;
994 assert!(!body.contains("id: 1\n"));
996 assert!(body.contains("id: 2\n"));
997 }
998
999 #[tokio::test]
1000 async fn empty_input_is_rejected_with_a_400() {
1001 let router = agent_sse_router(stub_factory(vec![]));
1002 let response = send_request(
1003 router,
1004 "POST",
1005 "/agent/stream",
1006 Some(serde_json::json!({"input": " "})),
1007 None,
1008 )
1009 .await;
1010 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
1011 }
1012
1013 #[tokio::test]
1014 async fn malformed_body_is_rejected_before_opening_the_stream() {
1015 let router = agent_sse_router(stub_factory(vec![]));
1016 let response = send_request(
1017 router,
1018 "POST",
1019 "/agent/stream",
1020 Some(serde_json::json!({"oops": true})),
1021 None,
1022 )
1023 .await;
1024 assert!(
1025 response.status().is_client_error(),
1026 "missing input should be a 4xx, got {}",
1027 response.status()
1028 );
1029 }
1030
1031 fn delayed_factory() -> Arc<dyn AgentStreamFactory> {
1035 Arc::new(|_input: String| async {
1036 let (tx, rx) = mpsc::channel::<AgentStreamEvent>(8);
1037 tokio::spawn(async move {
1038 tx.send(AgentStreamEvent::Text {
1039 content: "first".into(),
1040 })
1041 .await
1042 .ok();
1043 tokio::time::sleep(Duration::from_millis(80)).await;
1044 tx.send(AgentStreamEvent::FinalAnswer {
1045 content: "first".into(),
1046 })
1047 .await
1048 .ok();
1049 });
1050 Box::pin(ReceiverStream::new(rx)) as AgentEventStream
1051 })
1052 }
1053
1054 #[tokio::test]
1055 async fn served_connection_pushes_frames_incrementally_over_tcp() {
1056 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1057 use tokio::net::TcpListener;
1058
1059 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1060 let port = listener.local_addr().unwrap().port();
1061 tokio::spawn(async move {
1062 let _ = serve_agent_sse_on(delayed_factory(), listener).await;
1063 });
1064
1065 let mut conn = tokio::net::TcpStream::connect(("127.0.0.1", port))
1066 .await
1067 .unwrap();
1068 let body = r#"{"input":"hi"}"#;
1072 let request = format!(
1073 "POST /agent/stream HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1074 body.len(),
1075 body
1076 );
1077 conn.write_all(request.as_bytes()).await.unwrap();
1078
1079 let mut first = Vec::new();
1082 let mut chunk = [0u8; 4096];
1083 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1084 loop {
1085 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1086 let n = tokio::time::timeout(remaining, conn.read(&mut chunk))
1087 .await
1088 .expect("early frame should arrive promptly")
1089 .unwrap();
1090 if n == 0 {
1091 break;
1092 }
1093 first.extend_from_slice(&chunk[..n]);
1094 if first.windows(2).any(|w| w == b"\n\n") {
1096 break;
1097 }
1098 }
1099 let first = String::from_utf8(first).unwrap();
1100 assert!(first.contains("event: text"), "early frame: {first:?}");
1101 assert!(
1102 first.contains("{\"content\":\"first\"}"),
1103 "early frame: {first:?}"
1104 );
1105 assert!(
1106 !first.contains("final_answer"),
1107 "answer must not be buffered with the first frame: {first:?}"
1108 );
1109
1110 let mut rest = String::new();
1113 let mut rest_buf = [0u8; 4096];
1114 loop {
1115 match tokio::time::timeout(Duration::from_secs(5), conn.read(&mut rest_buf)).await {
1116 Ok(Ok(0)) => break,
1117 Ok(Ok(n)) => rest.push_str(&String::from_utf8_lossy(&rest_buf[..n])),
1118 Ok(Err(_)) => break,
1119 Err(_) => panic!("trailing frames never arrived: {rest:?}"),
1120 }
1121 }
1122 assert!(rest.contains("event: final_answer"), "tail: {rest:?}");
1123 assert!(rest.contains("event: done"), "tail: {rest:?}");
1124 }
1125
1126 #[tokio::test]
1127 async fn retry_config_is_sent_on_the_first_frame() {
1128 let events = vec![AgentStreamEvent::Text {
1129 content: "x".into(),
1130 }];
1131 let config = AgentSseServerConfig::default()
1132 .with_retry(Duration::from_secs(3))
1133 .with_heartbeat(Duration::from_secs(60));
1134 let router = agent_sse_router_with(stub_factory(events), config);
1135 let response = send_request(
1136 router,
1137 "POST",
1138 "/agent/stream",
1139 Some(serde_json::json!({"input": "hi"})),
1140 None,
1141 )
1142 .await;
1143 let body = body_text(response).await;
1144 assert!(
1147 body.starts_with("retry:3000\nid: 1\n"),
1148 "unexpected stream start: {body:?}"
1149 );
1150 }
1151}