Skip to main content

embacle_server/
streaming.rs

1// ABOUTME: Bridges embacle ChatStream to OpenAI-compatible Server-Sent Events format
2// ABOUTME: Converts StreamChunk items to "data: {json}\n\n" SSE with [DONE] terminator
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::convert::Infallible;
8
9use axum::response::sse::{Event, KeepAlive, Sse};
10use axum::response::{IntoResponse, Response};
11use embacle::types::{ChatStream, RunnerError, StreamChunk};
12use futures::StreamExt;
13use futures::{future, stream};
14
15use crate::completions::{generate_id, generate_tool_call_id, unix_timestamp};
16use crate::openai_types::{
17    ChatCompletionChunk, ChunkChoice, Delta, ResponseMessage, ToolCall, ToolCallFunction,
18};
19
20/// Convert a `ChatStream` into an SSE response in `OpenAI` streaming format
21///
22/// Emits:
23/// 1. An initial chunk with role="assistant" and empty content
24/// 2. Content delta chunks as they arrive from the provider
25/// 3. A final chunk with `finish_reason`
26/// 4. `data: [DONE]` terminator
27pub fn sse_response(stream: ChatStream, model: &str) -> Response {
28    let completion_id = generate_id();
29    let created = unix_timestamp();
30    let model = model.to_owned();
31
32    let sse_stream = {
33        let mut sent_role = false;
34
35        stream.map(move |chunk_result| {
36            match chunk_result {
37                Ok(chunk) => {
38                    let (role, content, finish_reason) = if !sent_role {
39                        sent_role = true;
40                        if chunk.delta.is_empty() && !chunk.is_final {
41                            // First chunk: role announcement only
42                            (Some("assistant"), None, None)
43                        } else {
44                            // First chunk has content: send role + content
45                            (Some("assistant"), Some(chunk.delta), chunk.finish_reason)
46                        }
47                    } else if chunk.is_final {
48                        (
49                            None,
50                            if chunk.delta.is_empty() {
51                                None
52                            } else {
53                                Some(chunk.delta)
54                            },
55                            Some(chunk.finish_reason.unwrap_or_else(|| "stop".to_owned())),
56                        )
57                    } else {
58                        (None, Some(chunk.delta), None)
59                    };
60
61                    // LinesStream strips trailing \n from each line. Restore it
62                    // so concatenated SSE deltas preserve original line breaks.
63                    let content = content.map(|c| {
64                        if !c.is_empty() && !c.ends_with('\n') {
65                            let mut normalized = c;
66                            normalized.push('\n');
67                            normalized
68                        } else {
69                            c
70                        }
71                    });
72
73                    let data = ChatCompletionChunk {
74                        id: completion_id.clone(),
75                        object: "chat.completion.chunk",
76                        created,
77                        model: model.clone(),
78                        choices: vec![ChunkChoice {
79                            index: 0,
80                            delta: Delta {
81                                role,
82                                content,
83                                tool_calls: None,
84                            },
85                            finish_reason,
86                        }],
87                    };
88
89                    let json = serde_json::to_string(&data).unwrap_or_default();
90                    Ok::<_, Infallible>(Event::default().data(json))
91                }
92                Err(e) => {
93                    let error_json = serde_json::json!({
94                        "error": {
95                            "message": e.message,
96                            "type": "stream_error"
97                        }
98                    });
99                    Ok(Event::default().data(error_json.to_string()))
100                }
101            }
102        })
103    };
104
105    // Append the [DONE] sentinel after the stream completes
106    let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
107
108    let combined = sse_stream.chain(done_stream);
109
110    Sse::new(combined)
111        .keep_alive(KeepAlive::default())
112        .into_response()
113}
114
115/// Convert a `ChatStream` into an SSE response, stripping markdown code fences
116///
117/// Used when `response_format` requests JSON. CLI runners often wrap JSON in
118/// `` ```json ... ``` `` fences that arrive as separate stream chunks. This
119/// variant filters those fence lines out so the client receives clean JSON.
120pub fn sse_response_strip_fences(stream: ChatStream, model: &str) -> Response {
121    let filtered = strip_fence_chunks(stream);
122    sse_response(filtered, model)
123}
124
125/// Wrap a `ChatStream` to remove chunks that are markdown code fences
126///
127/// Fence-only chunks (`` ```json ``, `` ``` ``) are dropped entirely.
128/// Final chunks with fence content have their delta cleared so the
129/// finish signal still propagates.
130fn strip_fence_chunks(stream: ChatStream) -> ChatStream {
131    use embacle::types::StreamChunk;
132
133    Box::pin(stream.filter_map(|result| async move {
134        match result {
135            Ok(chunk) => {
136                if is_markdown_fence(&chunk.delta) {
137                    if chunk.is_final {
138                        // Preserve the final signal with empty content
139                        Some(Ok(StreamChunk {
140                            delta: String::new(),
141                            is_final: true,
142                            finish_reason: chunk.finish_reason,
143                        }))
144                    } else {
145                        None
146                    }
147                } else {
148                    Some(Ok(chunk))
149                }
150            }
151            Err(e) => Some(Err(e)),
152        }
153    }))
154}
155
156/// Check if a stream chunk is a markdown code fence line (e.g. `` ```json `` or `` ``` ``)
157fn is_markdown_fence(text: &str) -> bool {
158    let trimmed = text.trim();
159    trimmed.starts_with("```") && trimmed.bytes().skip(3).all(|b| b.is_ascii_alphanumeric())
160}
161
162/// Emit a complete non-streaming response as an SSE event sequence
163///
164/// Used when the caller requested `stream: true` but the backend performed a
165/// non-streaming `complete()` (e.g. for tool-calling downgrade). Produces:
166/// 1. Role announcement chunk with content and/or `tool_calls`
167/// 2. Final chunk with `finish_reason`
168/// 3. `[DONE]` sentinel
169pub fn sse_single_response(message: ResponseMessage, finish_reason: &str, model: &str) -> Response {
170    let completion_id = generate_id();
171    let created = unix_timestamp();
172
173    let content_chunk = ChatCompletionChunk {
174        id: completion_id.clone(),
175        object: "chat.completion.chunk",
176        created,
177        model: model.to_owned(),
178        choices: vec![ChunkChoice {
179            index: 0,
180            delta: Delta {
181                role: Some("assistant"),
182                content: message.content,
183                tool_calls: message.tool_calls,
184            },
185            finish_reason: None,
186        }],
187    };
188
189    let final_chunk = ChatCompletionChunk {
190        id: completion_id,
191        object: "chat.completion.chunk",
192        created,
193        model: model.to_owned(),
194        choices: vec![ChunkChoice {
195            index: 0,
196            delta: Delta {
197                role: None,
198                content: None,
199                tool_calls: None,
200            },
201            finish_reason: Some(finish_reason.to_owned()),
202        }],
203    };
204
205    let events = vec![
206        serde_json::to_string(&content_chunk).unwrap_or_default(),
207        serde_json::to_string(&final_chunk).unwrap_or_default(),
208    ];
209
210    let event_stream = stream::iter(
211        events
212            .into_iter()
213            .map(|json| Ok::<_, Infallible>(Event::default().data(json))),
214    );
215    let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
216
217    let combined = event_stream.chain(done_stream);
218
219    Sse::new(combined)
220        .keep_alive(KeepAlive::default())
221        .into_response()
222}
223
224/// Opening marker for a text-simulated tool call block.
225const TOOL_OPEN: &str = "<tool_call>";
226/// Closing marker for a text-simulated tool call block.
227const TOOL_CLOSE: &str = "</tool_call>";
228
229/// A single item produced by the incremental tool-call scanner.
230enum Emit {
231    /// Prose content to forward as a content delta
232    Content(String),
233    /// A completed tool call to forward as a `tool_calls` delta
234    Tool(ToolCall),
235}
236
237/// Whether the scanner is reading prose or the body of a `<tool_call>` block.
238#[derive(Clone, Copy, PartialEq, Eq)]
239enum ScanMode {
240    /// Reading prose outside any tool-call block
241    Text,
242    /// Reading the body of an open `<tool_call>` block
243    Tool,
244}
245
246/// Incremental state machine that extracts `<tool_call>` blocks from a token
247/// stream while forwarding surrounding prose.
248///
249/// Tool-call markers and bodies may be split arbitrarily across stream chunks;
250/// the scanner buffers just enough to detect markers and never emits a partial
251/// `<tool_call>` opening tag as prose.
252struct ToolStreamState {
253    /// Unprocessed tail of the stream
254    buffer: String,
255    /// Whether the scanner is reading prose or a tool-call body
256    mode: ScanMode,
257    /// Number of tool calls emitted so far (drives id + index)
258    tool_index: usize,
259    /// Whether any tool call has been emitted (drives the finish reason)
260    emitted_tool: bool,
261    /// Whether the role announcement has been sent on the first delta
262    sent_role: bool,
263    /// Whether the terminal finish chunk has been emitted
264    finished: bool,
265}
266
267impl ToolStreamState {
268    fn new() -> Self {
269        Self {
270            buffer: String::new(),
271            mode: ScanMode::Text,
272            tool_index: 0,
273            emitted_tool: false,
274            sent_role: false,
275            finished: false,
276        }
277    }
278
279    /// Feed an incoming delta and return the items ready to emit.
280    fn process(&mut self, incoming: &str) -> Vec<Emit> {
281        self.buffer.push_str(incoming);
282        let mut out = Vec::new();
283
284        loop {
285            if self.mode == ScanMode::Tool {
286                if let Some(idx) = self.buffer.find(TOOL_CLOSE) {
287                    let inner = self.buffer[..idx].to_owned();
288                    self.buffer.drain(..idx + TOOL_CLOSE.len());
289                    self.mode = ScanMode::Text;
290                    if let Some(tool) = self.parse_tool(&inner) {
291                        out.push(Emit::Tool(tool));
292                    }
293                    continue;
294                }
295                break;
296            }
297
298            if let Some(idx) = self.buffer.find(TOOL_OPEN) {
299                if idx > 0 {
300                    out.push(Emit::Content(self.buffer[..idx].to_owned()));
301                }
302                self.buffer.drain(..idx + TOOL_OPEN.len());
303                self.mode = ScanMode::Tool;
304                continue;
305            }
306
307            // No complete opening marker — emit prose, holding back any suffix
308            // that could be the start of a `<tool_call>` tag split across chunks.
309            let safe = safe_prefix_len(&self.buffer);
310            if safe > 0 {
311                out.push(Emit::Content(self.buffer[..safe].to_owned()));
312                self.buffer.drain(..safe);
313            }
314            break;
315        }
316
317        out
318    }
319
320    /// Flush any buffered remainder when the stream ends.
321    ///
322    /// Unterminated tool blocks and held-back partial markers are emitted as
323    /// prose so no output is silently dropped.
324    fn finalize(&mut self) -> Vec<Emit> {
325        let mut out = Vec::new();
326        if !self.buffer.is_empty() {
327            let mut remainder = String::new();
328            if self.mode == ScanMode::Tool {
329                remainder.push_str(TOOL_OPEN);
330            }
331            remainder.push_str(&self.buffer);
332            self.buffer.clear();
333            out.push(Emit::Content(remainder));
334        }
335        self.mode = ScanMode::Text;
336        out
337    }
338
339    /// Parse the inner body of a `<tool_call>` block into an `OpenAI` tool call.
340    fn parse_tool(&mut self, inner: &str) -> Option<ToolCall> {
341        let block = format!("{TOOL_OPEN}{inner}{TOOL_CLOSE}");
342        let call = embacle::parse_tool_call_blocks(&block).into_iter().next()?;
343        let index = self.tool_index;
344        self.tool_index += 1;
345        self.emitted_tool = true;
346        Some(ToolCall {
347            index,
348            id: generate_tool_call_id(&call.name, index),
349            tool_type: "function".to_owned(),
350            function: ToolCallFunction {
351                name: call.name,
352                arguments: serde_json::to_string(&call.args).unwrap_or_else(|_| "{}".to_owned()),
353            },
354        })
355    }
356
357    /// Resolve the `OpenAI` finish reason for the terminal chunk.
358    fn finish_reason(&self, provider: Option<String>) -> String {
359        if self.emitted_tool {
360            "tool_calls".to_owned()
361        } else {
362            provider.unwrap_or_else(|| "stop".to_owned())
363        }
364    }
365
366    /// Take the role marker exactly once, for the first emitted delta.
367    fn take_role(&mut self) -> Option<&'static str> {
368        if self.sent_role {
369            None
370        } else {
371            self.sent_role = true;
372            Some("assistant")
373        }
374    }
375}
376
377/// Length of `buffer` that is safe to emit as prose without splitting a
378/// `<tool_call>` opening tag that may continue in a later chunk.
379///
380/// Holds back the longest trailing substring of `buffer` that is also a proper
381/// prefix of [`TOOL_OPEN`]. Because `TOOL_OPEN` is ASCII, the returned boundary
382/// is always a valid char boundary.
383fn safe_prefix_len(buffer: &str) -> usize {
384    let max = (TOOL_OPEN.len() - 1).min(buffer.len());
385    for k in (1..=max).rev() {
386        if buffer.as_bytes().ends_with(&TOOL_OPEN.as_bytes()[..k]) {
387            return buffer.len() - k;
388        }
389    }
390    buffer.len()
391}
392
393/// Convert a `ChatStream` into an SSE response that streams prose as content
394/// deltas and completed `<tool_call>` blocks as `tool_calls` deltas.
395///
396/// Unlike [`sse_single_response`], which buffers the whole completion, this
397/// forwards tokens incrementally. Used when a tools-bearing request targets a
398/// provider that supports streaming.
399pub fn sse_response_with_tool_calls(stream: ChatStream, model: &str) -> Response {
400    let id = generate_id();
401    let created = unix_timestamp();
402    let model = model.to_owned();
403
404    let mapped = stream
405        .scan(ToolStreamState::new(), move |state, chunk_result| {
406            let events = handle_tool_chunk(state, chunk_result, &id, created, &model);
407            future::ready(Some(stream::iter(events)))
408        })
409        .flatten();
410
411    let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
412    let combined = mapped.chain(done_stream);
413
414    Sse::new(combined)
415        .keep_alive(KeepAlive::default())
416        .into_response()
417}
418
419/// Process one input chunk into zero or more SSE events.
420fn handle_tool_chunk(
421    state: &mut ToolStreamState,
422    chunk_result: Result<StreamChunk, RunnerError>,
423    id: &str,
424    created: u64,
425    model: &str,
426) -> Vec<Result<Event, Infallible>> {
427    match chunk_result {
428        Ok(chunk) => {
429            let mut emits = state.process(&chunk.delta);
430            if chunk.is_final {
431                emits.extend(state.finalize());
432            }
433
434            let mut events: Vec<Result<Event, Infallible>> = emits
435                .into_iter()
436                .map(|emit| Ok(emit_to_event(state, emit, id, created, model)))
437                .collect();
438
439            if chunk.is_final && !state.finished {
440                state.finished = true;
441                let reason = state.finish_reason(chunk.finish_reason);
442                events.push(Ok(final_tool_event(id, created, model, &reason)));
443            }
444
445            events
446        }
447        Err(e) => {
448            let error_json = serde_json::json!({
449                "error": { "message": e.message, "type": "stream_error" }
450            });
451            vec![Ok(Event::default().data(error_json.to_string()))]
452        }
453    }
454}
455
456/// Build an SSE event for a single emitted item.
457fn emit_to_event(
458    state: &mut ToolStreamState,
459    emit: Emit,
460    id: &str,
461    created: u64,
462    model: &str,
463) -> Event {
464    let role = state.take_role();
465    let delta = match emit {
466        Emit::Content(text) => {
467            // LinesStream strips trailing newlines; restore one so concatenated
468            // deltas preserve line breaks, matching `sse_response`.
469            let content = if !text.is_empty() && !text.ends_with('\n') {
470                format!("{text}\n")
471            } else {
472                text
473            };
474            Delta {
475                role,
476                content: Some(content),
477                tool_calls: None,
478            }
479        }
480        Emit::Tool(tool_call) => Delta {
481            role,
482            content: None,
483            tool_calls: Some(vec![tool_call]),
484        },
485    };
486
487    let chunk = ChatCompletionChunk {
488        id: id.to_owned(),
489        object: "chat.completion.chunk",
490        created,
491        model: model.to_owned(),
492        choices: vec![ChunkChoice {
493            index: 0,
494            delta,
495            finish_reason: None,
496        }],
497    };
498    Event::default().data(serde_json::to_string(&chunk).unwrap_or_default())
499}
500
501/// Build the terminal SSE event carrying the finish reason.
502fn final_tool_event(id: &str, created: u64, model: &str, reason: &str) -> Event {
503    let chunk = ChatCompletionChunk {
504        id: id.to_owned(),
505        object: "chat.completion.chunk",
506        created,
507        model: model.to_owned(),
508        choices: vec![ChunkChoice {
509            index: 0,
510            delta: Delta {
511                role: None,
512                content: None,
513                tool_calls: None,
514            },
515            finish_reason: Some(reason.to_owned()),
516        }],
517    };
518    Event::default().data(serde_json::to_string(&chunk).unwrap_or_default())
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn is_markdown_fence_detects_fences() {
527        assert!(is_markdown_fence("```json\n"));
528        assert!(is_markdown_fence("```\n"));
529        assert!(is_markdown_fence("```json"));
530        assert!(is_markdown_fence("```"));
531        assert!(is_markdown_fence("  ```json  "));
532    }
533
534    #[test]
535    fn is_markdown_fence_rejects_non_fences() {
536        assert!(!is_markdown_fence("{\"key\": \"value\"}"));
537        assert!(!is_markdown_fence("some text"));
538        assert!(!is_markdown_fence(""));
539        assert!(!is_markdown_fence("```json is cool```"));
540        assert!(!is_markdown_fence("``` code here"));
541    }
542
543    /// Collect prose content from a sequence of emits.
544    fn collect_content(emits: &[Emit]) -> String {
545        emits
546            .iter()
547            .filter_map(|e| match e {
548                Emit::Content(c) => Some(c.as_str()),
549                Emit::Tool(_) => None,
550            })
551            .collect()
552    }
553
554    /// Collect tool calls from a sequence of emits.
555    fn collect_tools(emits: &[Emit]) -> Vec<&ToolCall> {
556        emits
557            .iter()
558            .filter_map(|e| match e {
559                Emit::Tool(t) => Some(t),
560                Emit::Content(_) => None,
561            })
562            .collect()
563    }
564
565    #[test]
566    fn safe_prefix_holds_back_partial_marker() {
567        // "abc<tool" must hold back "<tool" (a prefix of <tool_call>)
568        assert_eq!(safe_prefix_len("abc<tool"), 3);
569        // Full prose with no partial marker is fully emittable
570        assert_eq!(safe_prefix_len("hello world"), 11);
571        // A lone "<" is held back (could begin the marker)
572        assert_eq!(safe_prefix_len("done<"), 4);
573        // Text containing "<" not at a marker-prefix position is safe
574        assert_eq!(safe_prefix_len("a < b"), 5);
575    }
576
577    #[test]
578    fn process_passes_through_prose() {
579        let mut state = ToolStreamState::new();
580        let emits = state.process("Hello, world!");
581        assert_eq!(collect_content(&emits), "Hello, world!");
582        assert!(collect_tools(&emits).is_empty());
583    }
584
585    #[test]
586    fn process_extracts_text_then_tool_call() {
587        let mut state = ToolStreamState::new();
588        let input =
589            "Let me check.<tool_call>{\"name\":\"get_weather\",\"arguments\":{\"city\":\"Paris\"}}</tool_call>";
590        let emits = state.process(input);
591        assert_eq!(collect_content(&emits), "Let me check.");
592        let tools = collect_tools(&emits);
593        assert_eq!(tools.len(), 1);
594        assert_eq!(tools[0].function.name, "get_weather");
595        assert!(tools[0].function.arguments.contains("Paris"));
596        assert_eq!(tools[0].index, 0);
597        assert_eq!(tools[0].id, "call_get_weather_0");
598        assert!(state.emitted_tool);
599    }
600
601    #[test]
602    fn process_handles_marker_split_across_chunks() {
603        let mut state = ToolStreamState::new();
604        // Opening marker split mid-tag
605        let mut all = Vec::new();
606        all.extend(state.process("answer<tool"));
607        all.extend(state.process("_call>{\"name\":\"ping\","));
608        all.extend(state.process("\"arguments\":{}}</tool_call> done"));
609        // Prose before and after the tool call is preserved; the partial marker
610        // is never emitted as prose.
611        assert_eq!(collect_content(&all), "answer done");
612        let tools = collect_tools(&all);
613        assert_eq!(tools.len(), 1);
614        assert_eq!(tools[0].function.name, "ping");
615    }
616
617    #[test]
618    fn process_handles_multiple_tool_calls() {
619        let mut state = ToolStreamState::new();
620        let input = "<tool_call>{\"name\":\"a\",\"arguments\":{}}</tool_call><tool_call>{\"name\":\"b\",\"arguments\":{}}</tool_call>";
621        let emits = state.process(input);
622        let tools = collect_tools(&emits);
623        assert_eq!(tools.len(), 2);
624        assert_eq!(tools[0].index, 0);
625        assert_eq!(tools[1].index, 1);
626        assert_eq!(tools[1].id, "call_b_1");
627    }
628
629    #[test]
630    fn finalize_flushes_held_back_partial_as_prose() {
631        let mut state = ToolStreamState::new();
632        // A trailing "<" that looked like a possible marker start but never completed
633        let mut all = state.process("almost done<");
634        all.extend(state.finalize());
635        assert_eq!(collect_content(&all), "almost done<");
636    }
637
638    #[test]
639    fn finalize_flushes_unterminated_tool_block_as_prose() {
640        let mut state = ToolStreamState::new();
641        let mut all = state.process("<tool_call>{\"name\":\"x\"");
642        all.extend(state.finalize());
643        // Unterminated block is surfaced rather than dropped
644        assert!(collect_content(&all).contains("<tool_call>"));
645        assert!(collect_content(&all).contains("\"name\":\"x\""));
646    }
647
648    #[test]
649    fn finish_reason_reflects_tool_emission() {
650        let mut state = ToolStreamState::new();
651        assert_eq!(state.finish_reason(None), "stop");
652        assert_eq!(state.finish_reason(Some("length".to_owned())), "length");
653        state.emitted_tool = true;
654        assert_eq!(state.finish_reason(None), "tool_calls");
655    }
656
657    #[test]
658    fn take_role_emits_assistant_once() {
659        let mut state = ToolStreamState::new();
660        assert_eq!(state.take_role(), Some("assistant"));
661        assert_eq!(state.take_role(), None);
662    }
663
664    #[tokio::test]
665    async fn sse_with_tool_calls_emits_done_and_finish() {
666        use axum::body::to_bytes;
667
668        let chunks = vec![
669            Ok(StreamChunk {
670                delta: "Working<tool_call>{\"name\":\"ping\",\"arguments\":{}}</tool_call>"
671                    .to_owned(),
672                is_final: false,
673                finish_reason: None,
674            }),
675            Ok(StreamChunk {
676                delta: String::new(),
677                is_final: true,
678                finish_reason: Some("stop".to_owned()),
679            }),
680        ];
681        let input: ChatStream = Box::pin(stream::iter(chunks));
682        let response = sse_response_with_tool_calls(input, "copilot:gpt-5.4");
683        let body = to_bytes(response.into_body(), usize::MAX)
684            .await
685            .expect("body"); // Safe: test assertion
686        let text = String::from_utf8(body.to_vec()).expect("utf8"); // Safe: test assertion
687
688        assert!(text.contains("\"tool_calls\""));
689        assert!(text.contains("ping"));
690        // Tool emission overrides the provider's "stop" with "tool_calls"
691        assert!(text.contains("\"finish_reason\":\"tool_calls\""));
692        assert!(text.contains("[DONE]"));
693    }
694
695    #[tokio::test]
696    async fn strip_fence_chunks_removes_fences() {
697        use embacle::types::StreamChunk;
698
699        let chunks = vec![
700            Ok(StreamChunk {
701                delta: "```json\n".to_owned(),
702                is_final: false,
703                finish_reason: None,
704            }),
705            Ok(StreamChunk {
706                delta: "{\"key\":\"value\"}\n".to_owned(),
707                is_final: false,
708                finish_reason: None,
709            }),
710            Ok(StreamChunk {
711                delta: "```\n".to_owned(),
712                is_final: true,
713                finish_reason: Some("stop".to_owned()),
714            }),
715        ];
716
717        let input: ChatStream = Box::pin(stream::iter(chunks));
718        let filtered = strip_fence_chunks(input);
719
720        let results: Vec<_> = filtered.collect().await;
721        assert_eq!(results.len(), 2);
722
723        // First result is the actual JSON content
724        let first = results[0].as_ref().unwrap(); // Safe: test assertion
725        assert_eq!(first.delta, "{\"key\":\"value\"}\n");
726        assert!(!first.is_final);
727
728        // Second result is the final signal with empty delta (fence stripped)
729        let second = results[1].as_ref().unwrap(); // Safe: test assertion
730        assert!(second.delta.is_empty());
731        assert!(second.is_final);
732        assert_eq!(second.finish_reason.as_deref(), Some("stop"));
733    }
734}