Skip to main content

rig_core/providers/gemini/interactions_api/
streaming.rs

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
30/// The `event_type` values this client models on the Interactions SSE wire.
31///
32/// [`wire::classify_tagged_frame`] dispatches on this list: a frame whose
33/// `event_type` is outside it classifies `Unknown` (driver policy: warn +
34/// skip), while a listed value must pass the full [`InteractionSseEvent`]
35/// decode or classify `Corrupt`. There is no untagged serde fallback — policy
36/// lives in the classify layer, never in serde.
37const 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
47/// Classify one Interactions SSE frame. The single classify site for both
48/// consumers of this wire: the completion adapter below and the raw
49/// [`stream_interaction_events`] surface.
50fn 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/// Final metadata yielded by an Interactions streaming response.
57#[derive(Debug, Serialize, Deserialize, Default, Clone)]
58pub struct StreamingCompletionResponse {
59    pub usage: Option<InteractionUsage>,
60    pub interaction: Option<Interaction>,
61    /// Resolved model identifier (e.g. `gemini-2.5-pro-preview-05-06`), extracted from
62    /// `Interaction.model`. The Interactions API has no `FinishReason` field; use
63    /// `interaction.status` for lifecycle state.
64    #[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
92/// Normalize the Interactions API's terminal streaming record.
93///
94/// The finish reason comes from the completed interaction's lifecycle status —
95/// the API has no `finishReason` field — and is absent when the stream ended
96/// without one.
97fn 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    /// Open an Interactions stream whose terminal record stays provider-native.
120    ///
121    /// The normalized [`CompletionModel::stream`](crate::completion::CompletionModel::stream)
122    /// delegates here and maps only the terminal record, so both paths open
123    /// exactly one stream over the same request, telemetry, and error handling.
124    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
181/// The Gemini Interactions SSE wire as a [`WireAdapter`].
182///
183/// Frame-triage policy (warn on `Unknown`, in-band `Err` on `Corrupt`) lives
184/// in [`run_wire_stream`], not here — this ends the wire's former
185/// debug-log-and-skip handling of every decode failure.
186struct InteractionsAdapter {
187    /// Owns the constant-key thought lifecycle — the ends this wire never
188    /// announces are derived by the shared lifecycle, not hand-rolled here.
189    /// All accumulation lives in the shared accumulator.
190    reasoning: crate::providers::internal::chunk_lifecycle::MintedReasoningLifecycle,
191    /// A provider `error` event ended the turn; later frames are dead — the
192    /// provider aborted, and interpreting more output (or a terminal) would
193    /// dress the failure up as a completed turn.
194    failed: bool,
195    /// Function-call steps whose arguments may still stream as
196    /// `arguments_delta` fragments: the shared index → grammar-identity
197    /// bridge, keyed by the wire's step index. The wire announces the call
198    /// in `step.start` (usually with `"arguments": {}`, kept as the slot's
199    /// replace-if-no-deltas fallback), fragments the real payload across
200    /// `step.delta` `arguments_delta` events, and closes it with
201    /// `step.stop` — a genuine start/delta/end lifecycle. Recorded live in
202    /// `streaming_grammar/interactions_same_tool_twice`; the pre-fix code
203    /// emitted the empty-args call at `step.start` and dropped every
204    /// fragment.
205    ///
206    /// The bridge's minter is also the whole-call minter
207    /// ([`ToolCallBridge::minted_ids`]): both id-less paths draw from ONE
208    /// counter, so a step assembly and a whole call can never collide on
209    /// one minted key (the step-0 assembly used to share
210    /// `Minted(Tool, 0)` with every id-less whole call, and the whole call
211    /// silently swallowed the open assembly).
212    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                    // One lifecycle end covers every shape (open block,
275                    // already-closed block, signature-only stream); the
276                    // shared accumulator signs the right part — the missing
277                    // empty-buffer branch class (84a43e9e #2) cannot recur
278                    // because there is no branch.
279                    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                        // Interleaving content ends an open thought block —
294                        // the shared lifecycle synthesizes the boundary end.
295                        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                    // A function-call step opens an ASSEMBLY: the wire may
315                    // fragment the arguments as later `arguments_delta`
316                    // events at this index, so emitting a whole call here
317                    // would freeze the (usually empty) start-event payload
318                    // and drop every fragment. The bridge keys by the
319                    // wire's own id when present (never the tool name),
320                    // minting from the shared counter otherwise.
321                    let slot = self
322                        .open_function_steps
323                        .open(index, id.as_deref(), Some(&name));
324                    // The announce payload is NOT a fragment: fragments
325                    // append, and an announce that carries a partial (or
326                    // full) payload alongside later `arguments_delta`
327                    // events would concatenate into `{..}{..}`. It is kept
328                    // as the slot's fallback, used only when no fragment
329                    // ever arrives (replace-if-no-deltas).
330                    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                    // Tool content interleaving an open thought block: the
341                    // shared lifecycle synthesizes the boundary end.
342                    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                        // Interleaving content ends an open thought block —
356                        // the shared lifecycle synthesizes the boundary end.
357                        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                // The wire promised a complete function-call step: close its
371                // assembly. Malformed accumulated input surfaces in-band
372                // (`Error` policy), matching the other complete-block wires.
373                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                // A function-call step still open here was announced by
390                // `step.start` and — per this very event — belongs to a turn
391                // the provider COMPLETED: its `step.stop` was lost or
392                // reordered, not truncated away. Close each assembly with a
393                // synthesized end so the announced call finalizes from its
394                // accumulated fragments instead of vanishing in the
395                // accumulator's end-of-stream clear (which is reserved for
396                // genuine truncation, where the turn never finished). Wire
397                // (announcement) order keeps parallel calls deterministic.
398                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                // Only a genuine `interaction.completed` event counts as the
409                // provider completing the turn; the driver stops consuming
410                // after the terminal record. EOF without one is truncation and
411                // synthesizes nothing (see `finish`).
412                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                // Preserve the provider error payload (code + message) as the
423                // error body, matching the blocking path's
424                // `completion_error_from_body`. The event is re-serialized
425                // from its decoded form — the modeled fields survive. The
426                // error arrives over an established stream, so there is no
427                // HTTP status to attach (status: None).
428                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        // EOF without `interaction.completed` is truncation: no terminal
441        // record may be synthesized — it would report a successful completion
442        // for a turn the provider aborted.
443    }
444
445    fn is_finished(&self) -> bool {
446        // A provider `error` event is the wire's own in-band terminal:
447        // `interpret` already pushed the `Err` and gates itself on `failed`,
448        // so the driver must stop reading rather than drain the rest of the
449        // transport (and pass through post-error unknown frames).
450        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                    // Same frame-triage table as the completion path's
473                    // `run_wire_stream` driver — this surface yields typed
474                    // events rather than grammar events, so it applies the
475                    // driver's factored per-frame policy against the same
476                    // classify site instead of restating the table.
477                    match triage_frame(classify_interaction_frame(&message.data)) {
478                        Ok(TriagedFrame::Event(event)) => yield Ok(event),
479                        // This surface yields typed interaction events, not
480                        // grammar events — there is no raw passthrough item to
481                        // carry an unknown frame on, so it stays a warned skip
482                        // (the completion path surfaces Unknown via the
483                        // driver's `RawStreamingChoice::Unknown` passthrough).
484                        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
503/// Close an announced function-call step. The shared accumulator finalizes
504/// the call from its accumulated fragments; a step that fragmented nothing
505/// falls back to the payload it announced at `step.start` (and to a
506/// parameterless `{}` when it announced none) — the slot's
507/// replace-if-no-deltas fallback.
508///
509/// Interactions is a single-identifier wire: its id travels as `tool_id`
510/// only (`ToolCallSlot::end_event`'s shape). Filling `call_id` too made
511/// the accumulator take the dual-wire arm and store
512/// ProviderCallId{item_id: Some(fc_…)} — a fabricated Responses-shaped
513/// identity that slips past the foreign-id guard on cross-provider replay.
514/// Malformed accumulated input surfaces in-band (`Error` policy), matching
515/// the other complete-block wires.
516fn 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        // Every convertible item, in wire order: a `model_output` step can
528        // interleave text and function calls in one `content` list, and
529        // keeping only the first silently dropped the rest.
530        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            // The wire's id when present; never the tool name — a
543            // name-as-id fallback collides two same-tool calls in one turn.
544            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            // The wire's id when present; never the tool name — a
588            // name-as-id fallback collides two same-tool calls in one turn.
589            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        // Thought deltas (`thought_summary`, `thought_signature`) are
598        // stateful — the adapter accumulates and restates them in
599        // `interpret`, so they never reach this stateless mapping.
600        _ => 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        // Content deltas then EOF without `interaction.completed`: the
667        // truncated stream must deliver its content but never a synthesized
668        // terminal record.
669        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    /// Drive Interactions SSE frames through the full normalized path and
707    /// collect what the consumer sees, in order.
708    #[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    /// A `model_output` step interleaving text and a function call in one
747    /// step's `content`: every convertible item must surface, in wire
748    /// order. `find_map` kept only the first — a `function_call` following
749    /// text in the same step silently vanished.
750    #[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    /// A `step.start` that announces non-empty arguments AND fragments the
784    /// real payload across `arguments_delta` events: the deltas are the
785    /// arguments. Concatenating the announce payload with the fragments
786    /// yields `{..}{..}` — unparseable under the step's Error policy, so
787    /// the call was lost outright.
788    #[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    /// A partial announce with NO fragments: the announce payload is the
821    /// only arguments the wire sent, so it finalizes the call
822    /// (replace-if-no-deltas).
823    #[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    /// Interactions is a single-identifier wire: its `fc_…` id must land
850    /// in `provider.call_id` with `item_id` empty. Filling both slots
851    /// fabricated a Responses-shaped dual identity whose fake item id
852    /// passed the foreign-id guard on cross-provider replay.
853    #[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    /// A `step.stop` that never arrives must not lose the call: the wire
887    /// announced it (`step.start`), streamed its full arguments
888    /// (`arguments_delta`), and proved the turn finished
889    /// (`interaction.completed`). Before this fix the assembly stayed open,
890    /// `finish` never ran (terminal return), and the accumulator's
891    /// end-of-stream clear dropped the whole call — the agent then treated
892    /// a tool-calling turn as plain text.
893    #[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        // The turn completed normally: the terminal record survives too.
927        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        // A provider `error` event, then more frames: well-formed content, an
945        // unknown frame, and a terminal `interaction.completed`. The error
946        // must be the LAST item — the driver stops reading (`is_finished`),
947        // so nothing after it is interpreted or passed through as `Unknown`.
948        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        // Text-then-signature: the signed block must restate the full
982        // accumulated thought text and carry the signature; the aggregated
983        // choice keeps it (superseding the deltas), alongside the later text.
984        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        // The aggregated choice keeps exactly one reasoning part carrying the
1011        // signature — the signed restatement superseded the deltas.
1012        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        // Signature with no preceding thought-summary text: the signature is
1033        // the provider's replay-validated payload and must still survive as a
1034        // signed (empty-text) Reasoning block.
1035        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                // Single-identifier wire: the id travels as `tool_id` only.
1083                // Filling `call_id` too would take the dual-wire arm and
1084                // fabricate an item id the wire never issued.
1085                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}