1use async_stream::stream;
2use futures::{Stream, StreamExt};
3use serde::{Deserialize, Serialize};
4use std::pin::Pin;
5
6use super::interactions_api_types::{
7 Content, ContentDelta, FunctionCallContent, Interaction, InteractionSseEvent, InteractionUsage,
8 Step, TextDelta, ThoughtSignatureDelta, ThoughtSummaryContent, ThoughtSummaryDelta,
9 map_interaction_status,
10};
11use super::{InteractionsCompletionModel, PROVIDER_NAME, create_request_body};
12use crate::completion::{CompletionError, CompletionRequest};
13use crate::http_client::HttpClientExt;
14use crate::http_client::Request;
15use crate::http_client::sse::{Event, GenericEventSource};
16use crate::providers::gemini::streaming::shared_parts;
17use crate::providers::internal::sse_transport::{
18 OpenLog, SseTransportOptions, open_wire_stream, skip_blank_frames,
19};
20use crate::providers::internal::tool_call_bridge::ToolCallBridge;
21
22use crate::providers::internal::adapter::{
23 AdapterOutput, TriagedFrame, WireAdapter, WireFrame, triage_frame,
24};
25use crate::providers::internal::wire::{self, WireEvent};
26use crate::streaming;
27use crate::telemetry::{CompletionOperation, CompletionSpanBuilder, SpanCombinator};
28use serde_json::{Map, Value};
29
30const KNOWN_EVENT_TYPES: &[&str] = &[
38 "interaction.created",
39 "interaction.completed",
40 "interaction.status_update",
41 "step.start",
42 "step.delta",
43 "step.stop",
44 "error",
45];
46
47fn classify_interaction_frame(data: &str) -> WireEvent<InteractionSseEvent> {
51 wire::classify_tagged_frame(data, "event_type", |event_type| {
52 KNOWN_EVENT_TYPES.contains(&event_type)
53 })
54}
55
56#[derive(Debug, Serialize, Deserialize, Default, Clone)]
58pub struct StreamingCompletionResponse {
59 pub usage: Option<InteractionUsage>,
60 pub interaction: Option<Interaction>,
61 #[serde(skip_serializing_if = "Option::is_none")]
65 pub model_version: Option<String>,
66}
67
68#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
69pub type InteractionEventStream =
70 Pin<Box<dyn Stream<Item = Result<InteractionSseEvent, CompletionError>> + Send>>;
71
72#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
73pub type InteractionEventStream =
74 Pin<Box<dyn Stream<Item = Result<InteractionSseEvent, CompletionError>>>>;
75
76impl From<&StreamingCompletionResponse> for crate::completion::Usage {
77 fn from(value: &StreamingCompletionResponse) -> crate::completion::Usage {
78 value
79 .usage
80 .as_ref()
81 .map(crate::completion::Usage::from)
82 .unwrap_or_default()
83 }
84}
85
86impl From<StreamingCompletionResponse> for crate::completion::Usage {
87 fn from(value: StreamingCompletionResponse) -> crate::completion::Usage {
88 (&value).into()
89 }
90}
91
92fn map_stream_final(
98 response: StreamingCompletionResponse,
99) -> Result<streaming::StreamFinal, CompletionError> {
100 let usage = (&response).into();
101 let interaction = response.interaction.as_ref();
102 let finish_reason = interaction
103 .and_then(|interaction| interaction.status.as_ref())
104 .map(map_interaction_status);
105 let message_id = interaction
106 .map(|interaction| interaction.id.as_str())
107 .filter(|id| !id.is_empty());
108
109 Ok(streaming::StreamFinal::new(PROVIDER_NAME, usage)
110 .with_optional_finish_reason(finish_reason)
111 .with_optional_response_id(message_id)
112 .with_optional_model(response.model_version.as_deref()))
113}
114
115impl<T> InteractionsCompletionModel<T>
116where
117 T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
118{
119 pub async fn raw_stream(
125 &self,
126 completion_request: CompletionRequest,
127 ) -> Result<streaming::RawStreamingResult<StreamingCompletionResponse>, CompletionError> {
128 let span = CompletionSpanBuilder::new(
129 PROVIDER_NAME,
130 &self.model,
131 CompletionOperation::InteractionsStreaming,
132 )
133 .system_instructions(
134 completion_request.preamble.as_deref(),
135 completion_request.record_telemetry_content,
136 )
137 .build();
138
139 let request = create_request_body(self.model.clone(), completion_request, Some(true))?;
140
141 crate::providers::internal::trace_json(
142 crate::providers::internal::LogTarget::Streaming,
143 "Gemini interactions streaming request",
144 &request,
145 );
146
147 let body = serde_json::to_vec(&request)?;
148 let req = self
149 .client
150 .post_sse("/v1beta/interactions")?
151 .header("Content-Type", "application/json")
152 .body(body)
153 .map_err(|e| CompletionError::HttpError(e.into()))?;
154
155 Ok(open_wire_stream(
156 GenericEventSource::new(self.client.clone(), req),
157 SseTransportOptions {
158 open_log: OpenLog::Debug,
159 stream_ended_is_error: false,
160 log_transport_errors: true,
161 },
162 skip_blank_frames,
163 InteractionsAdapter::default(),
164 span,
165 ))
166 }
167
168 pub(crate) async fn stream(
169 &self,
170 completion_request: CompletionRequest,
171 ) -> Result<streaming::StreamingCompletionResponse, CompletionError> {
172 let inner = self.raw_stream(completion_request).await?;
173
174 Ok(streaming::StreamingCompletionResponse::stream(
175 PROVIDER_NAME,
176 streaming::normalize_stream(inner, map_stream_final),
177 ))
178 }
179}
180
181struct InteractionsAdapter {
187 reasoning: crate::providers::internal::chunk_lifecycle::MintedReasoningLifecycle,
191 failed: bool,
195 open_function_steps: ToolCallBridge<u32>,
213}
214
215impl Default for InteractionsAdapter {
216 fn default() -> Self {
217 Self {
218 reasoning: crate::providers::internal::chunk_lifecycle::MintedReasoningLifecycle::new(
219 shared_parts::REASONING_ID,
220 ),
221 failed: false,
222 open_function_steps: ToolCallBridge::new(),
223 }
224 }
225}
226
227impl WireAdapter for InteractionsAdapter {
228 type Frame = WireFrame;
229 type Event = InteractionSseEvent;
230 type Response = StreamingCompletionResponse;
231
232 fn classify(&self, frame: WireFrame) -> WireEvent<InteractionSseEvent> {
233 classify_interaction_frame(&frame.as_str())
234 }
235
236 fn interpret(&mut self, event: InteractionSseEvent, out: &mut AdapterOutput<Self::Response>) {
237 if self.failed {
238 return;
239 }
240
241 match event {
242 InteractionSseEvent::StepDelta { index, delta, .. } => match delta {
243 ContentDelta::ArgumentsDelta(arguments_delta) => {
244 if let (Some(slot), Some(fragment)) = (
245 self.open_function_steps.get_mut(index),
246 arguments_delta.arguments,
247 ) {
248 slot.saw_arguments_delta = true;
249 out.push(Ok(streaming::RawStreamingChoice::ToolCallDelta {
250 id: slot.key().clone(),
251 content: streaming::ToolCallDeltaContent::Delta(fragment),
252 }));
253 } else {
254 tracing::warn!(
255 step_index = index,
256 "arguments_delta with no open function-call step; dropping fragment"
257 );
258 }
259 }
260 ContentDelta::ThoughtSummary(ThoughtSummaryDelta { content }) => {
261 if let ThoughtSummaryContent::Text(text) = content {
262 self.reasoning.emit_chunk(
263 crate::providers::internal::chunk_lifecycle::ChunkParts {
264 reasoning: Some(text.text),
265 reasoning_signature: None,
266 text: None,
267 tool_events: Vec::new(),
268 },
269 out,
270 );
271 }
272 }
273 ContentDelta::ThoughtSignature(ThoughtSignatureDelta { signature }) => {
274 self.reasoning.emit_chunk(
280 crate::providers::internal::chunk_lifecycle::ChunkParts {
281 reasoning: None,
282 reasoning_signature: Some(signature),
283 text: None,
284 tool_events: Vec::new(),
285 },
286 out,
287 );
288 }
289 delta => {
290 if let Some(choice) =
291 content_delta_to_choice(delta, self.open_function_steps.minted_ids())
292 {
293 self.reasoning.emit_chunk(
296 crate::providers::internal::chunk_lifecycle::ChunkParts {
297 reasoning: None,
298 reasoning_signature: None,
299 text: None,
300 tool_events: vec![choice],
301 },
302 out,
303 );
304 }
305 }
306 },
307 InteractionSseEvent::StepStart { index, step, .. } => {
308 if let Step::FunctionCall(FunctionCallContent {
309 name: Some(name),
310 arguments,
311 id,
312 }) = step
313 {
314 let slot = self
322 .open_function_steps
323 .open(index, id.as_deref(), Some(&name));
324 slot.announce_arguments = arguments.filter(|arguments| {
331 arguments
332 .as_object()
333 .is_none_or(|object| !object.is_empty())
334 });
335 let key = slot.key().clone();
336 let tool_events = vec![streaming::RawStreamingChoice::ToolCallDelta {
337 id: key,
338 content: streaming::ToolCallDeltaContent::Name(name),
339 }];
340 self.reasoning.emit_chunk(
343 crate::providers::internal::chunk_lifecycle::ChunkParts {
344 reasoning: None,
345 reasoning_signature: None,
346 text: None,
347 tool_events,
348 },
349 out,
350 );
351 } else {
352 let choices =
353 step_start_to_choices(step, self.open_function_steps.minted_ids());
354 if !choices.is_empty() {
355 self.reasoning.emit_chunk(
358 crate::providers::internal::chunk_lifecycle::ChunkParts {
359 reasoning: None,
360 reasoning_signature: None,
361 text: None,
362 tool_events: choices,
363 },
364 out,
365 );
366 }
367 }
368 }
369 InteractionSseEvent::StepStop { index, .. } => {
370 if let Some(slot) = self.open_function_steps.remove(index) {
374 out.push(Ok(streaming::RawStreamingChoice::ToolInputEnd(
375 function_step_end(slot),
376 )));
377 }
378 }
379 InteractionSseEvent::InteractionCompleted { interaction, .. } => {
380 let span = tracing::Span::current();
381 span.record("gen_ai.response.id", &interaction.id);
382 if let Some(model) = interaction.model.clone() {
383 span.record("gen_ai.response.model", model);
384 }
385 if let Some(usage) = interaction.usage.as_ref() {
386 span.record_token_usage(&crate::completion::Usage::from(usage));
387 }
388
389 for (index, slot) in self.open_function_steps.drain_ordered_indexed() {
399 tracing::debug!(
400 index,
401 "closing a function-call step left open at interaction.completed"
402 );
403 out.push(Ok(streaming::RawStreamingChoice::ToolInputEnd(
404 function_step_end(slot),
405 )));
406 }
407
408 let model_version = interaction.model.clone();
413 out.push(Ok(streaming::RawStreamingChoice::FinalResponse(
414 StreamingCompletionResponse {
415 usage: interaction.usage.clone(),
416 interaction: Some(interaction),
417 model_version,
418 },
419 )));
420 }
421 event @ InteractionSseEvent::Error { .. } => {
422 self.failed = true;
429 let body = serde_json::to_string(&event).unwrap_or_default();
430 out.push(Err(crate::provider_response::completion_error_from_body(
431 body,
432 )));
433 }
434 InteractionSseEvent::InteractionCreated { .. }
435 | InteractionSseEvent::InteractionStatusUpdate { .. } => {}
436 }
437 }
438
439 fn finish(&mut self, _out: &mut AdapterOutput<Self::Response>) {
440 }
444
445 fn is_finished(&self) -> bool {
446 self.failed
451 }
452}
453
454pub(crate) fn stream_interaction_events<T>(
455 client: super::InteractionsClient<T>,
456 request: Request<Vec<u8>>,
457) -> InteractionEventStream
458where
459 T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
460{
461 let mut event_source = GenericEventSource::new(client.clone(), request);
462
463 let stream = stream! {
464 while let Some(event_result) = event_source.next().await {
465 match event_result {
466 Ok(Event::Open) => continue,
467 Ok(Event::Message(message)) => {
468 if message.data.trim().is_empty() {
469 continue;
470 }
471
472 match triage_frame(classify_interaction_frame(&message.data)) {
478 Ok(TriagedFrame::Event(event)) => yield Ok(event),
479 Ok(TriagedFrame::Unknown(_)) => {}
485 Err(error) => yield Err(error),
486 }
487 }
488 Err(crate::http_client::Error::StreamEnded) => break,
489 Err(error) => {
490 tracing::error!(?error, "SSE error");
491 yield Err(CompletionError::from_stream_transport(error));
492 break;
493 }
494 }
495 }
496
497 event_source.close();
498 };
499
500 Box::pin(stream)
501}
502
503fn function_step_end(
517 slot: crate::providers::internal::tool_call_bridge::ToolCallSlot,
518) -> streaming::ToolInputEnd {
519 slot.end_event(streaming::UnparseableToolInput::Error)
520}
521
522fn step_start_to_choices(
523 step: Step,
524 tool_ids: &mut streaming::SyntheticIds,
525) -> Vec<streaming::RawStreamingChoice<StreamingCompletionResponse>> {
526 match step {
527 Step::ModelOutput { content } => content
531 .into_iter()
532 .filter_map(|content| content_to_choice(content, tool_ids))
533 .collect(),
534 Step::FunctionCall(FunctionCallContent {
535 name,
536 arguments,
537 id,
538 }) => {
539 let Some(name) = name else {
540 return Vec::new();
541 };
542 vec![shared_parts::function_call(
545 name,
546 arguments.unwrap_or(Value::Object(Map::new())),
547 id,
548 None,
549 tool_ids,
550 )]
551 }
552 _ => Vec::new(),
553 }
554}
555
556fn content_to_choice(
557 content: Content,
558 tool_ids: &mut streaming::SyntheticIds,
559) -> Option<streaming::RawStreamingChoice<StreamingCompletionResponse>> {
560 match content {
561 Content::Text(text) if !text.text.is_empty() => {
562 Some(streaming::RawStreamingChoice::Message(text.text))
563 }
564 Content::FunctionCall(content) => {
565 step_start_to_choices(Step::FunctionCall(content), tool_ids)
566 .into_iter()
567 .next()
568 }
569 _ => None,
570 }
571}
572
573fn content_delta_to_choice(
574 delta: ContentDelta,
575 tool_ids: &mut streaming::SyntheticIds,
576) -> Option<streaming::RawStreamingChoice<StreamingCompletionResponse>> {
577 match delta {
578 ContentDelta::Text(TextDelta {
579 text: Some(text), ..
580 }) => Some(streaming::RawStreamingChoice::Message(text)),
581 ContentDelta::FunctionCall(FunctionCallContent {
582 name,
583 arguments,
584 id,
585 }) => {
586 let name = name?;
587 Some(shared_parts::function_call(
590 name,
591 arguments.unwrap_or(Value::Object(Map::new())),
592 id,
593 None,
594 tool_ids,
595 ))
596 }
597 _ => None,
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use serde_json::json;
608
609 #[test]
610 fn test_streaming_completion_response_has_model_version() {
611 let response = StreamingCompletionResponse {
612 usage: None,
613 interaction: None,
614 model_version: Some("gemini-2.5-pro-preview-05-06".to_string()),
615 };
616
617 assert_eq!(
618 response.model_version.as_deref(),
619 Some("gemini-2.5-pro-preview-05-06")
620 );
621
622 let json = serde_json::to_string(&response).unwrap();
623 let deserialized: StreamingCompletionResponse = serde_json::from_str(&json).unwrap();
624 assert_eq!(
625 deserialized.model_version.as_deref(),
626 Some("gemini-2.5-pro-preview-05-06")
627 );
628 }
629
630 #[test]
631 fn test_content_delta_text_event() {
632 let event_json = json!({
633 "event_type": "step.delta",
634 "index": 0,
635 "delta": {
636 "type": "text",
637 "text": "Hello"
638 }
639 });
640
641 let event: InteractionSseEvent = serde_json::from_value(event_json).unwrap();
642 let InteractionSseEvent::StepDelta { delta, .. } = event else {
643 panic!("expected step delta");
644 };
645
646 let choice = content_delta_to_choice(delta, &mut streaming::SyntheticIds::tool())
647 .expect("choice should exist");
648 match choice {
649 crate::streaming::RawStreamingChoice::Message(text) => {
650 assert_eq!(text, "Hello");
651 }
652 other => panic!("unexpected choice: {other:?}"),
653 }
654 }
655
656 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
657 #[tokio::test]
658 async fn truncated_stream_does_not_synthesize_a_terminal_record() {
659 use crate::client::CompletionClient;
660 use crate::completion::CompletionModel as _;
661 use crate::providers::gemini::Client;
662 use crate::streaming::StreamedAssistantContent;
663 use crate::test_utils::MockStreamingClient;
664 use futures::StreamExt;
665
666 let sse_bytes = bytes::Bytes::from(
670 [r#"{"event_type":"step.delta","index":0,"delta":{"type":"text","text":"hi"}}"#]
671 .iter()
672 .map(|event| format!("data: {event}\n\n"))
673 .collect::<String>(),
674 );
675
676 let client = Client::builder()
677 .api_key("test-key")
678 .http_client(MockStreamingClient { sse_bytes })
679 .build()
680 .expect("build client")
681 .interactions_api();
682 let model = client.completion_model("gemini-2.5-pro");
683 let request = model.completion_request("hello").build();
684 let mut stream = crate::completion::CompletionModel::stream(&model, request)
685 .await
686 .expect("stream should open");
687
688 let mut texts = Vec::new();
689 let mut saw_terminal = false;
690 while let Some(item) = stream.next().await {
691 match item.expect("stream item should be Ok") {
692 StreamedAssistantContent::Text(text) => texts.push(text.text),
693 StreamedAssistantContent::Final(_) => saw_terminal = true,
694 _ => {}
695 }
696 }
697
698 assert_eq!(texts, ["hi"]);
699 assert!(
700 !saw_terminal,
701 "EOF without interaction.completed must not synthesize a terminal record"
702 );
703 assert!(stream.response.is_none());
704 }
705
706 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
709 async fn drive_frames(
710 frames: &[&str],
711 ) -> (
712 Vec<Result<crate::streaming::StreamedAssistantContent, String>>,
713 crate::streaming::StreamingCompletionResponse,
714 ) {
715 use crate::client::CompletionClient;
716 use crate::completion::CompletionModel as _;
717 use crate::providers::gemini::Client;
718 use crate::test_utils::MockStreamingClient;
719 use futures::StreamExt;
720
721 let sse_bytes = bytes::Bytes::from(
722 frames
723 .iter()
724 .map(|event| format!("data: {event}\n\n"))
725 .collect::<String>(),
726 );
727 let client = Client::builder()
728 .api_key("test-key")
729 .http_client(MockStreamingClient { sse_bytes })
730 .build()
731 .expect("build client")
732 .interactions_api();
733 let model = client.completion_model("gemini-2.5-pro");
734 let request = model.completion_request("hello").build();
735 let mut stream = crate::completion::CompletionModel::stream(&model, request)
736 .await
737 .expect("stream should open");
738
739 let mut items = Vec::new();
740 while let Some(item) = stream.next().await {
741 items.push(item.map_err(|error| error.to_string()));
742 }
743 (items, stream)
744 }
745
746 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
751 #[tokio::test]
752 async fn a_model_output_step_yields_every_convertible_item() {
753 use crate::streaming::StreamedAssistantContent;
754
755 let (items, _stream) = drive_frames(&[
756 r#"{"event_type":"step.start","index":0,"step":{"type":"model_output","content":[{"type":"text","text":"answer: "},{"type":"function_call","name":"add","arguments":{"x":1},"id":"fc_9"}]}}"#,
757 r#"{"event_type":"interaction.completed","interaction":{"id":"int_1","status":"completed"}}"#,
758 ])
759 .await;
760
761 let mut texts = Vec::new();
762 let mut calls = Vec::new();
763 for item in &items {
764 match item {
765 Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text.clone()),
766 Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => {
767 calls.push(tool_call.clone())
768 }
769 _ => {}
770 }
771 }
772 assert_eq!(texts, ["answer: "], "the text survives, got {items:?}");
773 assert_eq!(
774 calls.len(),
775 1,
776 "the function_call after text must also survive, got {items:?}"
777 );
778 let call = calls.first().expect("one call");
779 assert_eq!(call.function.name, "add");
780 assert_eq!(call.function.arguments, serde_json::json!({"x": 1}));
781 }
782
783 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
789 #[tokio::test]
790 async fn announce_arguments_never_concatenate_with_fragments() {
791 use crate::streaming::StreamedAssistantContent;
792
793 let (items, _stream) = drive_frames(&[
794 r#"{"event_type":"step.start","index":1,"step":{"arguments":{"x":1},"id":"fc_1","name":"add","type":"function_call"}}"#,
795 r#"{"delta":{"arguments":"{\"x\":1}","type":"arguments_delta"},"event_type":"step.delta","index":1}"#,
796 r#"{"event_type":"step.stop","index":1}"#,
797 r#"{"event_type":"interaction.completed","interaction":{"id":"int_1","status":"completed"}}"#,
798 ])
799 .await;
800
801 let tool_calls: Vec<_> = items
802 .iter()
803 .filter_map(|item| match item {
804 Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => Some(tool_call),
805 _ => None,
806 })
807 .collect();
808 assert_eq!(
809 tool_calls.len(),
810 1,
811 "the announced-then-fragmented call must survive, got {items:?}"
812 );
813 assert_eq!(
814 tool_calls.first().expect("one call").function.arguments,
815 serde_json::json!({"x": 1}),
816 "streamed fragments are the arguments; the announce payload is not prepended"
817 );
818 }
819
820 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
824 #[tokio::test]
825 async fn announce_arguments_finalize_a_call_with_no_fragments() {
826 use crate::streaming::StreamedAssistantContent;
827
828 let (items, _stream) = drive_frames(&[
829 r#"{"event_type":"step.start","index":1,"step":{"arguments":{"x":7},"id":"fc_1","name":"add","type":"function_call"}}"#,
830 r#"{"event_type":"step.stop","index":1}"#,
831 r#"{"event_type":"interaction.completed","interaction":{"id":"int_1","status":"completed"}}"#,
832 ])
833 .await;
834
835 let tool_calls: Vec<_> = items
836 .iter()
837 .filter_map(|item| match item {
838 Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => Some(tool_call),
839 _ => None,
840 })
841 .collect();
842 assert_eq!(tool_calls.len(), 1, "got {items:?}");
843 assert_eq!(
844 tool_calls.first().expect("one call").function.arguments,
845 serde_json::json!({"x": 7})
846 );
847 }
848
849 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
854 #[tokio::test]
855 async fn a_streamed_call_carries_a_single_wire_identity() {
856 use crate::streaming::StreamedAssistantContent;
857
858 let (items, _stream) = drive_frames(&[
859 r#"{"event_type":"step.start","index":1,"step":{"arguments":{},"id":"fc_1","name":"add","type":"function_call"}}"#,
860 r#"{"delta":{"arguments":"{\"x\":1}","type":"arguments_delta"},"event_type":"step.delta","index":1}"#,
861 r#"{"event_type":"step.stop","index":1}"#,
862 r#"{"event_type":"interaction.completed","interaction":{"id":"int_1","status":"completed"}}"#,
863 ])
864 .await;
865
866 let tool_calls: Vec<_> = items
867 .iter()
868 .filter_map(|item| match item {
869 Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => Some(tool_call),
870 _ => None,
871 })
872 .collect();
873 let provider = tool_calls
874 .first()
875 .expect("one call")
876 .provider
877 .as_ref()
878 .expect("the wire issued an id");
879 assert_eq!(provider.call_id, "fc_1");
880 assert_eq!(
881 provider.item_id, None,
882 "a single-identifier wire must not fabricate a dual identity"
883 );
884 }
885
886 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
894 #[tokio::test]
895 async fn a_missing_step_stop_does_not_lose_the_announced_call() {
896 use crate::streaming::StreamedAssistantContent;
897
898 let (items, stream) = drive_frames(&[
899 r#"{"event_type":"step.start","index":1,"step":{"arguments":{},"id":"fc_1","name":"get_weather","type":"function_call"}}"#,
900 r#"{"delta":{"arguments":"{\"city\":\"Paris\"}","type":"arguments_delta"},"event_type":"step.delta","index":1}"#,
901 r#"{"event_type":"interaction.completed","interaction":{"id":"int_1","status":"completed"}}"#,
902 ])
903 .await;
904
905 let tool_calls: Vec<_> = items
906 .iter()
907 .filter_map(|item| match item {
908 Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => Some(tool_call),
909 _ => None,
910 })
911 .collect();
912 assert_eq!(
913 tool_calls.len(),
914 1,
915 "the announced call must survive the missing step.stop, got {items:?}"
916 );
917 let tool_call = tool_calls.first().expect("one call");
918 assert_eq!(tool_call.function.name, "get_weather");
919 assert_eq!(
920 tool_call.function.arguments,
921 serde_json::json!({"city": "Paris"}),
922 "the streamed argument fragments finalize the call"
923 );
924 assert_eq!(tool_call.id, "fc_1");
925
926 assert!(stream.response.is_some());
928 let aggregated_calls = stream
929 .choice
930 .iter()
931 .filter(|content| matches!(content, crate::message::AssistantContent::ToolCall(_)))
932 .count();
933 assert_eq!(
934 aggregated_calls, 1,
935 "the call reaches the aggregated choice"
936 );
937 }
938
939 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
940 #[tokio::test]
941 async fn provider_error_event_ends_the_stream_without_draining_later_frames() {
942 use crate::streaming::StreamedAssistantContent;
943
944 let (items, stream) = drive_frames(&[
949 r#"{"event_type":"step.delta","index":0,"delta":{"type":"text","text":"hi"}}"#,
950 r#"{"event_type":"error","error":{"code":"internal","message":"boom"}}"#,
951 r#"{"event_type":"step.delta","index":0,"delta":{"type":"text","text":"dead"}}"#,
952 r#"{"event_type":"something.future","payload":{"x":1}}"#,
953 r#"{"event_type":"interaction.completed","interaction":{"id":"int_1","status":"completed"}}"#,
954 ])
955 .await;
956
957 let error_position = items
958 .iter()
959 .position(|item| item.is_err())
960 .expect("the provider error must reach the consumer");
961 assert_eq!(
962 error_position,
963 items.len() - 1,
964 "the in-band error must end the stream: no later text, Unknown passthrough, or terminal; got {items:?}"
965 );
966 assert!(
967 items.iter().any(|item| matches!(
968 item,
969 Ok(StreamedAssistantContent::Text(text)) if text.text == "hi"
970 )),
971 "content before the error must survive"
972 );
973 assert!(stream.response.is_none());
974 }
975
976 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
977 #[tokio::test]
978 async fn thought_signature_completes_the_accumulated_reasoning_block() {
979 use crate::streaming::StreamedAssistantContent;
980
981 let (items, stream) = drive_frames(&[
985 r#"{"event_type":"step.delta","index":0,"delta":{"type":"thought_summary","content":{"type":"text","text":"think1 "}}}"#,
986 r#"{"event_type":"step.delta","index":0,"delta":{"type":"thought_summary","content":{"type":"text","text":"think2"}}}"#,
987 r#"{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":"sig-abc"}}"#,
988 r#"{"event_type":"step.delta","index":1,"delta":{"type":"text","text":"answer"}}"#,
989 ])
990 .await;
991
992 let signed = items
993 .iter()
994 .find_map(|item| match item {
995 Ok(StreamedAssistantContent::Reasoning { reasoning, .. }) => {
996 Some(reasoning.clone())
997 }
998 _ => None,
999 })
1000 .expect("the signature must yield a completed Reasoning block");
1001 assert_eq!(
1002 signed.content,
1003 vec![crate::completion::message::ReasoningContent::Text {
1004 text: "think1 think2".to_string(),
1005 signature: Some("sig-abc".to_string()),
1006 }],
1007 "the signed block must restate the accumulated text with the signature"
1008 );
1009
1010 let aggregated: Vec<_> = stream
1013 .choice
1014 .iter()
1015 .filter_map(|content| match content {
1016 crate::completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
1017 _ => None,
1018 })
1019 .collect();
1020 assert_eq!(aggregated.len(), 1, "got {:?}", stream.choice);
1021 assert_eq!(
1022 aggregated.first().map(|r| r.content.clone()),
1023 Some(signed.content)
1024 );
1025 }
1026
1027 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1028 #[tokio::test]
1029 async fn signature_only_thought_still_carries_the_signature() {
1030 use crate::streaming::StreamedAssistantContent;
1031
1032 let (items, _stream) = drive_frames(&[
1036 r#"{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":"sig-only"}}"#,
1037 r#"{"event_type":"step.delta","index":1,"delta":{"type":"text","text":"answer"}}"#,
1038 ])
1039 .await;
1040
1041 let signed = items
1042 .iter()
1043 .find_map(|item| match item {
1044 Ok(StreamedAssistantContent::Reasoning { reasoning, .. }) => {
1045 Some(reasoning.clone())
1046 }
1047 _ => None,
1048 })
1049 .expect("a signature-only block must still yield a signed Reasoning");
1050 assert_eq!(
1051 signed.content,
1052 vec![crate::completion::message::ReasoningContent::Text {
1053 text: String::new(),
1054 signature: Some("sig-only".to_string()),
1055 }]
1056 );
1057 }
1058
1059 #[test]
1060 fn test_content_delta_function_call_event() {
1061 let event_json = json!({
1062 "event_type": "step.delta",
1063 "index": 0,
1064 "delta": {
1065 "type": "function_call",
1066 "name": "get_weather",
1067 "arguments": {"location": "Paris"},
1068 "id": "call-1"
1069 }
1070 });
1071
1072 let event: InteractionSseEvent = serde_json::from_value(event_json).unwrap();
1073 let InteractionSseEvent::StepDelta { delta, .. } = event else {
1074 panic!("expected step delta");
1075 };
1076
1077 let choice = content_delta_to_choice(delta, &mut streaming::SyntheticIds::tool())
1078 .expect("choice should exist");
1079 match choice {
1080 crate::streaming::RawStreamingChoice::ToolCall(call) => {
1081 assert_eq!(call.name, "get_weather");
1082 assert_eq!(call.tool_id.as_ref().map(|id| id.as_str()), Some("call-1"));
1086 assert_eq!(call.call_id, None);
1087 }
1088 other => panic!("unexpected choice: {other:?}"),
1089 }
1090 }
1091}