Skip to main content

lc_agents/streaming/
sse.rs

1// lc-agents/src/streaming/sse.rs
2//! Agent events → Server-Sent Events framing (B8, v0.22.4).
3//!
4//! Turns a stream of [`AgentStreamEvent`] into
5//! [`text/event-stream`](https://html.spec.whatwg.org/multipage/server-sent-events.html)
6//! frames any SSE client can consume. The module is web-framework agnostic:
7//! [`agent_sse_frames`] emits [`SseFrame`]s and [`encode_sse_frame`] renders
8//! the exact wire bytes, so actix/rocket/a hand-rolled hyper service can
9//! serve them. The `sse-server` feature adds an axum 0.7 endpoint
10//! (`agent_sse_router`, `agent_sse_handler`).
11//!
12//! # Wire contract
13//!
14//! | SSE `event:` | Backed by | Payload (`data:` JSON, one compact line) |
15//!|---|---|---|
16//! | `text`          | [`AgentStreamEvent::Text`]        | `{"content": "..."}` |
17//! | `tool_call`     | [`AgentStreamEvent::ToolCall`]    | `{"state":"started"\|"arguments_streaming"\|"arguments_complete"\|"executing"\|"completed"\|"failed", "tool_name", "call_id", ...}` |
18//! | `tool_start`    | [`AgentStreamEvent::ToolStart`]   | `{"name","input"}` |
19//! | `tool_end`      | [`AgentStreamEvent::ToolEnd`]     | `{"name","output"}` |
20//! | `pipeline_step` | [`AgentStreamEvent::PipelineStep`]| `{"step","detail": string\|null}` |
21//! | `final_answer`  | [`AgentStreamEvent::FinalAnswer`] | `{"content":"..."}` |
22//! | `error`         | [`AgentStreamEvent::Error`]       | `{"message":"..."}` |
23//!
24//! Every content frame carries a monotonic `id:` starting at 1. The run ends
25//! with an **unnumbered** `done` frame (`data:{"status":"done"}`); an error
26//! during execution arrives as `event:error` and is still followed by `done`.
27//! With [`SseOptions::with_heartbeat`], idle periods emit SSE comment frames
28//! (`: keep-alive`, no id) so proxies cannot idle-timeout the connection.
29//! With [`SseOptions::with_retry`], the first content frame carries a
30//! `retry:` reconnection hint (milliseconds).
31//!
32//! # Routes (`sse-server` feature)
33//!
34//! - `POST /agent/stream` with a JSON [`AgentSseRequest`] — for programmatic
35//!   / `fetch()` streaming clients (arbitrarily long prompts).
36//! - `GET /agent/stream?input=...` — native browser `EventSource` is
37//!   GET-only, so the same run is reachable from `new EventSource(url)`.
38//!
39//! # Disconnect / resume semantics
40//!
41//! The frame stream is driven by a spawned task that forwards from the agent
42//! stream. When the HTTP client goes away, axum drops the response future,
43//! this stream drops, and the agent producer observes a closed channel on its
44//! next send — the run is cancelled promptly (see the module's disconnect
45//! test). Cross-connection run resumption (replaying an already-finished run)
46//! is deliberately out of scope for a stateless endpoint; clients reconnect
47//! and send `Last-Event-ID` (header, or [`AgentSseRequest::last_event_id`]),
48//! which [`SseOptions::resume_from`] honors **within the new run's** frame
49//! sequence (frames at or below the id are suppressed) — enough for
50//! dedupe-aware clients, without pretending a dropped run can be rewound
51//! server-side.
52
53use 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/// One rendered-but-not-yet-encoded SSE message.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum SseFrame {
69    /// A named event with an optional last-event-id, an optional `retry:`
70    /// reconnection hint, and a JSON payload.
71    Event {
72        /// Monotonic event id (absent on the terminal `done` frame).
73        id: Option<u64>,
74        /// SSE `retry:` hint in milliseconds (sent on the first frame only).
75        retry: Option<Duration>,
76        /// SSE `event:` field, e.g. `text`.
77        event: &'static str,
78        /// Compact, single-line JSON payload for the `data:` field.
79        data: String,
80    },
81    /// An SSE comment line (`: …`), used for keep-alive heartbeats.
82    Comment(String),
83}
84
85impl SseFrame {
86    /// Creates a numbered content event frame.
87    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/// Options for [`agent_sse_frames`].
98#[derive(Debug, Clone, Default)]
99pub struct SseOptions {
100    /// Suppress frames whose id is at or below this value (SSE `Last-Event-ID`).
101    pub resume_from: Option<u64>,
102    /// When set, emit a `: keep-alive` comment whenever no event arrives for
103    /// this long.
104    pub heartbeat: Option<Duration>,
105    /// When set, the first content frame carries an SSE `retry:` hint.
106    pub retry: Option<Duration>,
107}
108
109impl SseOptions {
110    /// Suppresses frames at or below `last_event_id` (SSE `Last-Event-ID`).
111    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    /// Emits keep-alive comments on idle connections at the given interval.
117    pub fn with_heartbeat(mut self, interval: Duration) -> Self {
118        self.heartbeat = Some(interval);
119        self
120    }
121
122    /// Sends the SSE `retry:` reconnection hint on the first content frame.
123    pub fn with_retry(mut self, retry: Duration) -> Self {
124        self.retry = Some(retry);
125        self
126    }
127}
128
129/// Maps an agent event to its stable SSE event name.
130pub 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
142/// Maps an agent event to its compact single-line JSON `data:` payload.
143pub 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    // State-specific key first; tool_name/call_id are uniform across states.
167    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
195/// Renders an [`SseFrame`] into its exact `text/event-stream` wire form.
196///
197/// The data payload is emitted verbatim — callers pass compact JSON from
198/// [`sse_event_payload`] (always one physical line; embedded newlines are
199/// JSON-escaped, never raw). A blank line terminates the frame.
200pub 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            // SSE spec: every physical line of a multi-line datum gets its own
218            // `data:` prefix. Compact JSON is single-line, but honor the rule
219            // for hand-built payloads too.
220            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
229/// Agent event stream with a concrete item type, as produced by
230/// [`StreamingFunctionCallingAgent::invoke_stream`](crate::streaming::StreamingFunctionCallingAgent::invoke_stream).
231pub type AgentEventStream = Pin<Box<dyn Stream<Item = AgentStreamEvent> + Send>>;
232
233/// Wraps an agent event stream with SSE framing: monotonic ids, optional
234/// `Last-Event-ID` suppression, idle heartbeats, a `retry:` hint, and a
235/// terminal `done` frame.
236///
237/// Framing runs in a spawned task; dropping the returned stream stops polling
238/// the agent so its producer task unwinds instead of running to completion.
239pub 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        // The interval's first tick is immediate — burn it so the heartbeat
251        // measures idle time rather than firing at stream start.
252        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                // Client went away: stop polling the agent so its producer
296                // task unwinds instead of running to completion.
297                return;
298            }
299        }
300
301        // Terminal marker carries no id: reconnecting after `done` must not
302        // resume past the end of a (new) run.
303        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/// Request body for the `POST /agent/stream` endpoint (`sse-server` feature).
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct AgentSseRequest {
319    /// User input forwarded verbatim to the agent.
320    pub input: String,
321    /// Optional client-side `Last-Event-ID`; an actual `Last-Event-ID`
322    /// request header takes precedence over this field.
323    #[serde(default)]
324    pub last_event_id: Option<u64>,
325}
326
327/// Query parameters for the `GET /agent/stream?input=...` route, which exists
328/// so browser-native `EventSource` (GET-only) can consume the stream.
329#[cfg(feature = "sse-server")]
330#[derive(Debug, Clone, Deserialize)]
331pub struct AgentSseQuery {
332    /// User input forwarded verbatim to the agent.
333    pub input: String,
334    /// Optional `Last-Event-ID` equivalent; the real header wins.
335    #[serde(default)]
336    pub last_event_id: Option<u64>,
337}
338
339/// Default keep-alive interval for the built-in router: proxies and browsers
340/// commonly idle-cut SSE connections at 30–60s, so beat well under that.
341#[cfg(feature = "sse-server")]
342pub const DEFAULT_SSE_HEARTBEAT: Duration = Duration::from_secs(20);
343
344/// Server-wide settings for the built-in axum router.
345#[cfg(feature = "sse-server")]
346#[derive(Debug, Clone, Default)]
347pub struct AgentSseServerConfig {
348    /// Idle heartbeat interval. `None` (the default) means
349    /// [`DEFAULT_SSE_HEARTBEAT`]; an explicit [`Duration::ZERO`] (set via
350    /// [`AgentSseServerConfig::without_heartbeat`]) disables heartbeats.
351    pub heartbeat: Option<Duration>,
352    /// Optional SSE `retry:` hint sent on the first frame of every run.
353    pub retry: Option<Duration>,
354}
355
356#[cfg(feature = "sse-server")]
357impl AgentSseServerConfig {
358    /// Sets the idle heartbeat interval.
359    pub fn with_heartbeat(mut self, interval: Duration) -> Self {
360        self.heartbeat = Some(interval);
361        self
362    }
363
364    /// Disables idle heartbeats.
365    pub fn without_heartbeat(mut self) -> Self {
366        self.heartbeat = Some(Duration::ZERO);
367        self
368    }
369
370    /// Sets the SSE `retry:` reconnection hint.
371    pub fn with_retry(mut self, retry: Duration) -> Self {
372        self.retry = Some(retry);
373        self
374    }
375}
376
377/// Boxed future returned by an [`AgentStreamFactory`].
378#[cfg(feature = "sse-server")]
379pub type AgentStreamFuture = Pin<Box<dyn std::future::Future<Output = AgentEventStream> + Send>>;
380
381/// Builds a fresh agent event stream per HTTP request.
382///
383/// A blanket impl covers `Fn(String) -> Fut`, so an `Arc`-shared agent is
384/// wired with a move closure that clones the Arc and awaits its stream:
385///
386/// ```ignore
387/// let agent = Arc::new(StreamingFunctionCallingAgent::new(chat));
388/// let factory: Arc<dyn AgentStreamFactory> = Arc::new(move |input: String| {
389///     let agent = agent.clone();
390///     async move { agent.invoke_stream(input).await }
391/// });
392/// ```
393#[cfg(feature = "sse-server")]
394pub trait AgentStreamFactory: Send + Sync {
395    /// Starts one run and returns its event stream.
396    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/// Router state: the per-request stream factory plus server-wide options.
411#[cfg(feature = "sse-server")]
412#[derive(Clone)]
413pub struct AgentSseState {
414    factory: Arc<dyn AgentStreamFactory>,
415    config: AgentSseServerConfig,
416}
417
418/// The boxed SSE response body used by the axum handlers.
419#[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    // ZERO is the explicit "heartbeats disabled" marker.
443    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            // Every byte source here is newline-free: compact JSON, static
474            // event names, numeric ids, and the fixed "keep-alive" comment.
475            let event = match frame {
476                // In axum 0.7 only `data()` is fallible (it rejects raw
477                // newlines/carriage returns); event/id/comment/retry setters
478                // return `Self`.
479                SseFrame::Comment(text) => axum::response::sse::Event::default().comment(text),
480                SseFrame::Event {
481                    id,
482                    retry,
483                    event,
484                    data,
485                } => {
486                    // axum 0.7 appends fields in call order and splits `data`
487                    // on '\n' itself; match the core encoder's wire order
488                    // (retry, id, event, data). Payloads are compact JSON.
489                    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/// `POST /agent/stream`: JSON [`AgentSseRequest`] in, `text/event-stream` out.
505#[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/// `GET /agent/stream?input=...`: same run, reachable from native
515/// `EventSource` (which cannot POST).
516#[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/// Conservative CORS layer, mirrored from lc-a2a: only localhost development
526/// origins are allowed cross-origin; other browser origins get no allow
527/// header and are blocked. Restrict or replace for real deployments.
528#[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/// Serves the default SSE router on `0.0.0.0:{port}`.
545///
546/// Returns when the server shuts down. Bind a listener yourself and use
547/// [`serve_agent_sse_on`] for custom addresses, TLS, unix sockets, or
548/// ephemeral test ports.
549#[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/// Serves the default SSE router on an already-bound listener.
560#[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/// Builds a router exposing `POST|GET /agent/stream` with the default
569/// [`DEFAULT_SSE_HEARTBEAT`] keep-alive and a localhost-only CORS layer.
570#[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/// Builds a router with explicit [`AgentSseServerConfig`] (heartbeat, retry).
576#[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        // 5 numbered content frames + unnumbered done.
631        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        // A stream that never yields and never ends. The heartbeat interval
792        // is a real, short wall-clock interval so the spawned framer task is
793        // driven like it is in production (virtual time across spawned tasks
794        // is flakier than it is worth here).
795        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        // Feed one event through the whole pipeline.
820        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        // Simulate the HTTP client going away: the response stream is dropped.
829        drop(frames);
830
831        // The framer drains queued events into its dead output channel, then
832        // stops polling; the agent producer's subsequent sends must fail (run
833        // cancellation), not block or succeed forever.
834        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        // Header wins over the body field: only frame id 2 survives.
995        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    /// Factory whose producer pushes text immediately, pauses, then pushes the
1032    /// final answer — lets a real socket test observe frames arriving at
1033    /// distinct moments rather than one buffered body.
1034    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        // Content-Length must be computed from the body: hyper waits for the
1069        // declared number of body bytes before dispatching, so a length that is
1070        // off by one silently hangs the request.
1071        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        // First read must already carry the early text frame but not the
1080        // answer — i.e. frames hit the wire as they happen, no buffering.
1081        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            // Stop as soon as one complete frame (blank-line terminator) is in.
1095            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        // After the producer's pause, drain the remainder and expect the
1111        // final answer plus the terminal done frame.
1112        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        // axum 0.7 hand-rolls `retry:` without the optional space; either
1145        // spelling is spec-valid (our own encoder emits `retry: 3000`).
1146        assert!(
1147            body.starts_with("retry:3000\nid: 1\n"),
1148            "unexpected stream start: {body:?}"
1149        );
1150    }
1151}