Skip to main content

rig_agent/agent/run/
streamed.rs

1//! Streamed-turn assembly for [`AgentRun`](super::AgentRun).
2//!
3//! A streamed model turn arrives as incremental [`StreamedAssistantContent`]
4//! items. [`StreamedTurnAssembler`] is the sans-IO accumulator that turns that
5//! item stream into the same canonical complete turn the non-streaming path
6//! feeds the machine — while telling the driver what to forward to its
7//! consumer and surfacing invalid tool calls the moment they appear, so a
8//! driver can stop paying for a doomed provider stream early.
9//!
10//! The protocol, paired with the streamed entry points on
11//! [`AgentRun`](super::AgentRun):
12//!
13//! 1. On [`AgentRunStep::CallModel`](super::AgentRunStep::CallModel), open a
14//!    provider stream and create one assembler per turn with the tool names
15//!    advertised for that turn.
16//! 2. Feed every stream item to [`StreamedTurnAssembler::ingest`] and act on
17//!    the returned [`StreamedTurnEvent`]s: forward items to the consumer, and
18//!    on [`StreamedTurnEvent::InvalidToolCall`] consult
19//!    [`AgentRun::resolve_streamed_invalid_tool_call`](super::AgentRun::resolve_streamed_invalid_tool_call) —
20//!    [`StreamedResolution::Repaired`] continues the same stream via
21//!    [`StreamedTurnAssembler::resolve_pending_invalid`];
22//!    [`StreamedResolution::TurnAbandoned`] means drain the provider stream
23//!    for usage and re-enter
24//!    [`AgentRun::next_step`](super::AgentRun::next_step).
25//! 3. When the provider stream ends, call [`StreamedTurnAssembler::finish`]
26//!    and feed the result to
27//!    [`AgentRun::streamed_turn`](super::AgentRun::streamed_turn); the run
28//!    then proceeds exactly like a non-streamed one
29//!    ([`CallTools`](super::AgentRunStep::CallTools) /
30//!    [`Done`](super::AgentRunStep::Done)).
31//!
32//! [`crate::streaming::StreamingPrompt::stream_prompt`] drives this protocol
33//! internally; hand-driven runs can use it to stream any
34//! [`AgentRun`](super::AgentRun).
35
36use std::collections::{BTreeSet, HashMap};
37
38use serde::{Deserialize, Serialize};
39
40use rig_core::completion::FinishReason;
41use rig_core::message::{
42    AssistantContent, Reasoning, ToolCall, ToolFunction, ToolResult, non_empty,
43};
44
45use crate::{
46    agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_message},
47    completion::{CompletionError, Message, Usage},
48    json_utils,
49    streaming::{StreamedAssistantContent, ToolCallDeltaContent},
50};
51
52/// Assemble assistant content in canonical replay order: reasoning blocks,
53/// then text, then trailing items (tool calls, images). Maps its inputs 1:1,
54/// so the result is empty exactly when every input is.
55pub(crate) fn ordered_assistant_content(
56    reasoning_items: impl IntoIterator<Item = Reasoning>,
57    text_items: impl IntoIterator<Item = AssistantContent>,
58    trailing_items: impl IntoIterator<Item = AssistantContent>,
59) -> Vec<AssistantContent> {
60    let mut content_items = reasoning_items
61        .into_iter()
62        .map(AssistantContent::Reasoning)
63        .collect::<Vec<_>>();
64    content_items.extend(text_items);
65    content_items.extend(trailing_items);
66    content_items
67}
68
69/// [`ordered_assistant_content`], as an `Option` for slots where an empty
70/// assembly means "no message".
71pub(crate) fn ordered_streaming_assistant_content(
72    reasoning_items: impl IntoIterator<Item = Reasoning>,
73    text_items: impl IntoIterator<Item = AssistantContent>,
74    trailing_items: impl IntoIterator<Item = AssistantContent>,
75) -> Option<Vec<AssistantContent>> {
76    non_empty(ordered_assistant_content(
77        reasoning_items,
78        text_items,
79        trailing_items,
80    ))
81}
82
83/// Whether a [`StreamedAssistantContent::Unknown`] payload is rig assistant
84/// content, so excluding it from assembly loses transcript content.
85///
86/// The predicate is the decoder itself — a payload that parses as a tagged
87/// [`AssistantContent`] block (`toolcall`/`reasoning`/`image` today, every
88/// future variant automatically) is a replayed assistant block, not a
89/// stream-item shape: the untagged stream variants carry different keys, so
90/// it lands in `Unknown` and its content would silently vanish. A dropped
91/// tool call additionally desyncs the turn — no pending call, no result.
92///
93/// Well-formed text does not reach this path: the tolerant block decode
94/// ignores unknown keys, so a tagged text block or a text item with stray
95/// sibling keys (0.41's flatten shape) decodes as
96/// `StreamedAssistantContent::Text` and its text is *assembled*, with only
97/// the stray keys dropped. The one way a text-carrying item can still land
98/// in `Unknown` is a *malformed known field* — a non-object
99/// `additional_params` fails the strict decode — and that item carries real
100/// text, so it counts too. Anything else in `Unknown` is a provider-native
101/// unmodeled item and stays quiet.
102///
103/// The whole outcome space is pinned by the decode-outcome matrix test
104/// (`decode_outcome_matrix_is_total_and_no_shape_is_silent`): assembled,
105/// excluded-and-counted, or excluded-quiet — no shape is silent.
106fn unknown_payload_loses_assistant_content(payload: &serde_json::Value) -> bool {
107    // `&Value` is itself a `Deserializer`, so the probe allocates nothing —
108    // this runs on every `Unknown` item, and provider-native payloads can be
109    // large and frequent.
110    if AssistantContent::deserialize(payload).is_ok() {
111        return true;
112    }
113    // A string `text` alongside an `additional_params` key: a text item
114    // whose params were malformed enough to fail even the tolerant decode.
115    // Its text is real transcript content.
116    payload
117        .get("text")
118        .is_some_and(serde_json::Value::is_string)
119        && payload.get("additional_params").is_some()
120}
121
122pub(crate) fn assistant_text_items_from_choice(
123    choice: &[AssistantContent],
124) -> Vec<AssistantContent> {
125    choice
126        .iter()
127        .filter_map(|content| match content {
128            AssistantContent::Text(text) => (!text.text.is_empty()
129                || text.additional_params.is_some())
130            .then(|| AssistantContent::Text(text.clone())),
131            _ => None,
132        })
133        .collect()
134}
135
136/// One invalid tool call surfaced mid-stream, awaiting a resolution from
137/// [`AgentRun::resolve_streamed_invalid_tool_call`](super::AgentRun::resolve_streamed_invalid_tool_call).
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct StreamedInvalidToolCall {
140    /// The rejected tool call. For a name delta this is a diagnostic call
141    /// assembled from the streamed name and any buffered argument deltas.
142    pub tool_call: ToolCall,
143    /// Rig-generated identifier correlating this call's stream items.
144    pub internal_call_id: String,
145    /// Raw argument payload for diagnostics, when available.
146    pub args: Option<String>,
147    /// Executable Rig tools advertised to the provider for this turn.
148    pub executable_tool_names: BTreeSet<String>,
149    /// Tools allowed by the active tool choice for this turn.
150    pub allowed_tool_names: BTreeSet<String>,
151}
152
153/// Snapshot of a streamed turn at the moment an invalid tool call appeared.
154/// Used by the machine to build diagnostics and rollback messages from
155/// exactly what the model has produced so far.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct PartialStreamedTurn {
158    /// Provider-assigned assistant message ID, when already known.
159    pub message_id: Option<String>,
160    /// Aggregated assistant text, when any text was streamed this turn.
161    pub text: Option<String>,
162    /// Accumulated reasoning, with any pending unsigned delta text assembled
163    /// into a block.
164    pub reasoning: Vec<Reasoning>,
165    /// Tool calls already validated (or repaired) this turn.
166    pub pending_tool_calls: Vec<ToolCall>,
167}
168
169impl PartialStreamedTurn {
170    /// The assistant message representing this partial turn, in canonical
171    /// order, including `current_tool_call` when provided. `None` when the
172    /// turn has produced no representable content.
173    pub(crate) fn assistant_message(&self, current_tool_call: Option<ToolCall>) -> Option<Message> {
174        let text_items = match &self.text {
175            Some(text) if !text.is_empty() => vec![AssistantContent::text(text.clone())],
176            _ => Vec::new(),
177        };
178        let mut tool_items = self
179            .pending_tool_calls
180            .iter()
181            .cloned()
182            .map(AssistantContent::ToolCall)
183            .collect::<Vec<_>>();
184        if let Some(tool_call) = current_tool_call {
185            tool_items.push(AssistantContent::ToolCall(tool_call));
186        }
187
188        let content = ordered_streaming_assistant_content(
189            self.reasoning.iter().cloned(),
190            text_items,
191            tool_items,
192        )?;
193        Some(Message::Assistant {
194            id: self.message_id.clone(),
195            content,
196        })
197    }
198
199    /// Rollback messages for a retried or skipped streamed turn: the partial
200    /// assistant turn plus a user message carrying `feedback` for the invalid
201    /// call and a synthetic "not executed" result for each validated peer.
202    pub(crate) fn rollback_messages(
203        &self,
204        invalid_tool_call: ToolCall,
205        feedback: String,
206    ) -> Option<(Message, Message)> {
207        // Every call — the invalid one and each validated peer — already
208        // carries a unique, non-empty `ToolCallId` (minted at the provider
209        // boundary when the wire issued none), so both sides of this
210        // fabricated transcript pair correlate by id with no local minting
211        // and no peer left holding an empty sentinel.
212        let assistant_message = self.assistant_message(Some(invalid_tool_call.clone()))?;
213
214        let mut retry_results = self
215            .pending_tool_calls
216            .iter()
217            .map(|tool_call| {
218                tool_result_message(
219                    tool_call.id.clone(),
220                    tool_call.provider.clone(),
221                    tool_call.function.name.clone(),
222                    TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
223                )
224            })
225            .collect::<Vec<_>>();
226        retry_results.push(tool_result_message(
227            invalid_tool_call.id,
228            invalid_tool_call.provider,
229            invalid_tool_call.function.name,
230            feedback,
231        ));
232
233        // `retry_results` is non-empty: the invalid call's own feedback result
234        // was just pushed unconditionally.
235        let user_message = Message::User {
236            content: retry_results,
237        };
238
239        Some((assistant_message, user_message))
240    }
241}
242
243/// The assembled streamed turn, fed to
244/// [`AgentRun::streamed_turn`](super::AgentRun::streamed_turn).
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct StreamedTurn {
247    /// Provider-assigned assistant message ID, when available.
248    pub message_id: Option<String>,
249    /// The assistant content to record in history: canonical
250    /// (reasoning → text → tool calls) when the turn produced reasoning or
251    /// tool calls, otherwise the provider's aggregated choice as-is.
252    pub choice: Vec<AssistantContent>,
253    /// Executable Rig tools advertised to the provider for this turn.
254    pub executable_tool_names: BTreeSet<String>,
255    /// Tools allowed by the active tool choice for this turn.
256    pub allowed_tool_names: BTreeSet<String>,
257    /// `(tool_call_id, internal_call_id)` pairs for this turn's tool calls,
258    /// in emission order. Carried into the run state so a resumed process
259    /// keeps the IDs consumers already saw in tool-call deltas.
260    #[serde(default)]
261    pub internal_call_ids: Vec<(String, String)>,
262    /// Why the provider stopped generating this turn, when it reported a
263    /// reason — the streamed analogue of [`ModelTurn::finish_reason`], so a
264    /// driver that feeds turns through `streamed_turn` records the same
265    /// terminal reason the blocking surface does (rig#2322).
266    ///
267    /// [`ModelTurn::finish_reason`]: super::ModelTurn::finish_reason
268    #[serde(default)]
269    pub finish_reason: Option<FinishReason>,
270}
271
272/// What the machine decided about a mid-stream invalid tool call.
273///
274/// Deliberately exhaustive: a driver must handle every resolution, so adding
275/// a variant is a breaking change by design.
276#[derive(Debug)]
277pub enum StreamedResolution {
278    /// The tool name was repaired. Apply it via
279    /// [`StreamedTurnAssembler::resolve_pending_invalid`] and keep consuming
280    /// the provider stream.
281    Repaired {
282        /// The validated replacement tool name.
283        tool_name: String,
284    },
285    /// The turn was rolled back (retry) or the call skipped; corrective
286    /// messages are already in the history. Drain the provider stream for
287    /// usage, record the completion call, then call
288    /// [`AgentRun::next_step`](super::AgentRun::next_step).
289    TurnAbandoned {
290        /// For a skipped call, the synthetic tool result to surface to the
291        /// consumer stream. Boxed: the result dwarfs the other variant.
292        skipped_tool_result: Option<Box<ToolResult>>,
293    },
294}
295
296/// What a driver must do with one ingested stream item.
297///
298/// Deliberately exhaustive: a driver must handle every event, so adding a
299/// variant is a breaking change by design.
300#[derive(Debug, Clone)]
301pub enum StreamedTurnEvent {
302    /// Forward the ingested item to the consumer as-is (text, reasoning, or
303    /// reasoning deltas, after accumulation).
304    EmitIngested,
305    /// Forward this tool-call delta. Argument deltas buffered while the tool
306    /// name awaited validation are replayed through this event.
307    EmitToolCallDelta {
308        /// Rig-generated identifier correlating this call's stream items.
309        internal_call_id: String,
310        /// The (possibly repaired) name or argument delta.
311        content: ToolCallDeltaContent,
312    },
313    /// The model emitted an unknown or disallowed tool call. Resolve it via
314    /// [`AgentRun::resolve_streamed_invalid_tool_call`](super::AgentRun::resolve_streamed_invalid_tool_call),
315    /// then apply the outcome with
316    /// [`StreamedTurnAssembler::resolve_pending_invalid`].
317    InvalidToolCall(Box<StreamedInvalidToolCall>),
318    /// The provider supplied its typed final payload. Record its usage (see
319    /// [`AgentRun::record_streamed_completion_call`](super::AgentRun::record_streamed_completion_call));
320    /// this does not establish that the provider stream reached EOF. When
321    /// `emit_final` is set, the turn streamed text and the driver should buffer
322    /// the final item until EOF finalizes the turn.
323    Completed {
324        /// Provider-reported usage for this call. Zero-valued usage means the
325        /// provider reported no usage metrics.
326        usage: Usage,
327        /// Whether the ingested final item should be forwarded to the
328        /// consumer (set when the turn streamed text).
329        emit_final: bool,
330        /// Why the provider stopped generating, when it reported a reason.
331        ///
332        /// Previously dropped here: the assembler read `usage` and `saw_text`
333        /// off the terminal record and discarded the rest, so a turn truncated
334        /// at the output-token limit reached the driver indistinguishable from
335        /// one that simply stopped (rig#2322).
336        finish_reason: Option<FinishReason>,
337    },
338}
339
340#[derive(Default)]
341struct ToolCallDeltaState {
342    name_validated: bool,
343    buffered_arguments: Vec<String>,
344}
345
346/// One reasoning part of the turn, in first-arrival order. A part opens as
347/// delta text keyed by the stream's rig-generated correlator and is
348/// superseded in place when a completed block restating the same part
349/// arrives; a completed block matching no open part occupies its own slot.
350struct ReasoningPart {
351    correlator: Option<String>,
352    provider_id: Option<String>,
353    state: ReasoningPartState,
354}
355
356#[derive(Clone)]
357enum ReasoningPartState {
358    /// Delta text accumulated so far for a part with no completed block.
359    Pending(String),
360    /// The authoritative completed block (may carry signatures or encrypted
361    /// content the deltas lacked).
362    Completed(Reasoning),
363}
364
365/// Assemble one part's reasoning: a completed block as-is, a non-empty pending
366/// delta buffer as its own block carrying only the part's provider-issued id.
367fn reasoning_from_part(
368    state: ReasoningPartState,
369    provider_id: Option<String>,
370) -> Option<Reasoning> {
371    match state {
372        ReasoningPartState::Completed(reasoning) => Some(reasoning),
373        ReasoningPartState::Pending(text) if !text.is_empty() => {
374            let mut assembled = Reasoning::new(&text);
375            if let Some(id) = provider_id {
376                assembled = assembled.with_id(id);
377            }
378            Some(assembled)
379        }
380        ReasoningPartState::Pending(_) => None,
381    }
382}
383
384enum PendingInvalid {
385    /// A complete tool call with a disallowed name.
386    FullCall {
387        tool_call: Box<ToolCall>,
388        internal_call_id: String,
389    },
390    /// A streamed tool-name delta with a disallowed name.
391    NameDelta { internal_call_id: String },
392}
393
394/// Sans-IO accumulator that assembles one streamed model turn. See the
395/// [module docs](self) for the driving protocol.
396pub struct StreamedTurnAssembler {
397    executable_tool_names: BTreeSet<String>,
398    allowed_tool_names: BTreeSet<String>,
399    text: String,
400    saw_text: bool,
401    reasoning_parts: Vec<ReasoningPart>,
402    pending_tool_calls: Vec<(ToolCall, String)>,
403    delta_states: HashMap<String, ToolCallDeltaState>,
404    pending_invalid: Option<PendingInvalid>,
405    /// Terminal reason from this turn's provider final record, retained so
406    /// [`Self::finish`] can carry it onto the [`StreamedTurn`] (rig#2322).
407    finish_reason: Option<FinishReason>,
408    /// Replayed assistant blocks excluded from assembly this turn (see
409    /// [`unknown_payload_loses_assistant_content`]): counted per item,
410    /// surfaced as one warning when the guard drops.
411    excluded_assistant_content: ExclusionCount,
412}
413
414/// Count of replayed assistant blocks excluded from assembly in one turn.
415///
416/// The loudness contract lives on this guard's `Drop`, so it holds on
417/// *every* termination path — `finish`, stream errors, hook cancellation,
418/// abandonment, truncation — exactly once, and zero exclusions stay silent.
419/// A dedicated one-field guard (not a `Drop` impl on the assembler itself)
420/// keeps the assembler's fields freely movable.
421#[derive(Default)]
422struct ExclusionCount(usize);
423
424impl Drop for ExclusionCount {
425    fn drop(&mut self) {
426        if self.0 > 0 {
427            tracing::warn!(
428                excluded = self.0,
429                "stream items matching rig's tagged assistant-content \
430                 serialization were excluded from the assembled assistant \
431                 message — replayed assistant blocks are not stream-item \
432                 shapes, and their content is lost from assembled history"
433            );
434        }
435    }
436}
437
438impl StreamedTurnAssembler {
439    /// Create an assembler for one streamed turn with the tool names
440    /// advertised to the provider for that turn.
441    pub fn new(
442        executable_tool_names: BTreeSet<String>,
443        allowed_tool_names: BTreeSet<String>,
444    ) -> Self {
445        Self {
446            executable_tool_names,
447            allowed_tool_names,
448            text: String::new(),
449            saw_text: false,
450            reasoning_parts: Vec::new(),
451            pending_tool_calls: Vec::new(),
452            delta_states: HashMap::new(),
453            pending_invalid: None,
454            finish_reason: None,
455            excluded_assistant_content: ExclusionCount::default(),
456        }
457    }
458
459    /// Replayed assistant blocks excluded from assembly so far this turn.
460    /// Zero on well-formed provider streams; non-zero means transcript
461    /// content was lost (one warning summarizes the count at
462    /// [`Self::finish`]).
463    pub fn excluded_assistant_content(&self) -> usize {
464        self.excluded_assistant_content.0
465    }
466
467    /// Aggregated assistant text streamed so far this turn (empty until the
468    /// first text delta).
469    pub fn aggregated_text(&self) -> &str {
470        &self.text
471    }
472
473    /// Reasoning text accumulated for the currently pending part identified by
474    /// `correlator`.
475    ///
476    /// Completed parts are deliberately skipped: a later delta may reuse a
477    /// correlator after a completed restatement, in which case ingestion opens
478    /// a new pending part and this returns that new part's aggregate.
479    pub fn aggregated_reasoning(&self, correlator: &str) -> Option<&str> {
480        self.reasoning_parts.iter().find_map(|part| {
481            match (&part.state, part.correlator.as_deref()) {
482                (ReasoningPartState::Pending(text), Some(id)) if id == correlator => {
483                    Some(text.as_str())
484                }
485                _ => None,
486            }
487        })
488    }
489
490    /// Normalize the provider aggregate into the content committed for this
491    /// turn. The reasoning is supplied by the caller: the finish path drains
492    /// its parts by value ([`Self::drain_reasoning`]) instead of cloning
493    /// them, while the partial-turn surface assembles borrowed
494    /// ([`Self::assembled_reasoning`]) — agreement between the two is pinned
495    /// by `canonical_choice_and_partial_turn_agree_on_multi_part_reasoning`.
496    fn canonical_choice_with(
497        &self,
498        reasoning: Vec<Reasoning>,
499        provider_choice: &[AssistantContent],
500    ) -> Vec<AssistantContent> {
501        if !self.pending_tool_calls.is_empty() || !reasoning.is_empty() {
502            let text_items = assistant_text_items_from_choice(provider_choice);
503            let tool_items = self
504                .pending_tool_calls
505                .iter()
506                .map(|(tool_call, _)| AssistantContent::ToolCall(tool_call.clone()))
507                .collect::<Vec<_>>();
508            // Infallible on purpose: the enclosing guard makes at least one
509            // input non-empty and the assembly maps its inputs 1:1, so there
510            // is no empty case to fall back from.
511            ordered_assistant_content(reasoning, text_items, tool_items)
512        } else {
513            provider_choice.to_vec()
514        }
515    }
516
517    /// Record a completed reasoning block. It supersedes the same part —
518    /// matched by the stream correlator first, regardless of whether that
519    /// part is still pending or already completed (the stream restates one
520    /// correlator per part, so a later same-correlator completion is the
521    /// same part's authoritative whole, e.g. a signed restatement after an
522    /// unsigned close), then by the durable provider id for pending parts —
523    /// because the completed block restates the delta text plus payloads
524    /// (signatures, encrypted content) the deltas lacked. A block matching
525    /// no part by correlator merges with an earlier completed block sharing
526    /// its provider id, else occupies a new slot; unmatched pending buffers
527    /// are never dropped (a delta-only visible part and a completed
528    /// encrypted block can coexist in one stream).
529    fn ingest_completed_reasoning(&mut self, reasoning: &Reasoning, correlator: &str) {
530        // An exact correlator match IS the part, whatever its state:
531        // replace wholesale (pydantic-ai's replace-part semantics — the
532        // completed block always carries the whole content, the
533        // accumulator having merged signatures before yielding). Checked
534        // before the provider-id fallbacks so a signed restatement can
535        // never double-extend its own part. Failing that, the block
536        // supersedes a pending part sharing its durable provider id.
537        let replace_at = self
538            .reasoning_parts
539            .iter()
540            .position(|part| part.correlator.as_deref() == Some(correlator))
541            .or_else(|| {
542                self.reasoning_parts.iter().position(|part| {
543                    matches!(part.state, ReasoningPartState::Pending(_))
544                        && matches!(
545                            (&part.provider_id, &reasoning.id),
546                            (Some(pending_id), Some(incoming_id)) if pending_id == incoming_id
547                        )
548                })
549            });
550        if let Some(part) = replace_at.and_then(|index| self.reasoning_parts.get_mut(index)) {
551            if reasoning.id.is_some() {
552                part.provider_id = reasoning.id.clone();
553            }
554            part.state = ReasoningPartState::Completed(reasoning.clone());
555            return;
556        }
557
558        // Completed blocks sharing a provider-issued id extend one
559        // another (the multi-part same-id reasoning item shape).
560        let extends = self.reasoning_parts.iter_mut().rev().find(|part| {
561            matches!(part.state, ReasoningPartState::Completed(_))
562                && matches!(
563                    (&part.provider_id, &reasoning.id),
564                    (Some(existing_id), Some(incoming_id)) if existing_id == incoming_id
565                )
566        });
567        if let Some(part) = extends {
568            if let ReasoningPartState::Completed(existing) = &mut part.state {
569                existing.content.extend(reasoning.content.clone());
570            }
571            return;
572        }
573
574        self.reasoning_parts.push(ReasoningPart {
575            correlator: Some(correlator.to_owned()),
576            provider_id: reasoning.id.clone(),
577            state: ReasoningPartState::Completed(reasoning.clone()),
578        });
579    }
580
581    /// The turn's reasoning in first-arrival order: completed blocks as-is,
582    /// non-empty pending delta buffers each assembled into their own block
583    /// carrying only the part's provider-issued id.
584    fn assembled_reasoning(&self) -> Vec<Reasoning> {
585        self.reasoning_parts
586            .iter()
587            .filter_map(|part| reasoning_from_part(part.state.clone(), part.provider_id.clone()))
588            .collect()
589    }
590
591    /// [`Self::assembled_reasoning`], consuming the parts — the finish path
592    /// owns the assembler, and reasoning blocks can carry large encrypted
593    /// payloads that should move rather than clone.
594    fn drain_reasoning(&mut self) -> Vec<Reasoning> {
595        std::mem::take(&mut self.reasoning_parts)
596            .into_iter()
597            .filter_map(|part| reasoning_from_part(part.state, part.provider_id))
598            .collect()
599    }
600
601    /// Ingest one provider stream item and return what the driver must do.
602    ///
603    /// # Errors
604    /// Returns an error when the provider stream is inconsistent (argument
605    /// deltas finishing without a validated tool name) or when an invalid
606    /// tool call is still awaiting resolution.
607    pub fn ingest(
608        &mut self,
609        item: &StreamedAssistantContent,
610    ) -> Result<Vec<StreamedTurnEvent>, CompletionError> {
611        if self.pending_invalid.is_some() {
612            return Err(CompletionError::ResponseError(
613                "streamed turn ingested while an invalid tool call awaits resolution".to_string(),
614            ));
615        }
616
617        match item {
618            StreamedAssistantContent::Text(text) => {
619                if !self.saw_text {
620                    self.text.clear();
621                    self.saw_text = true;
622                }
623                self.text.push_str(&text.text);
624                Ok(vec![StreamedTurnEvent::EmitIngested])
625            }
626            StreamedAssistantContent::Reasoning { reasoning, id } => {
627                self.ingest_completed_reasoning(reasoning, id);
628                Ok(vec![StreamedTurnEvent::EmitIngested])
629            }
630            StreamedAssistantContent::ReasoningDelta {
631                id,
632                reasoning,
633                provider_id,
634            } => {
635                // Deltas lack signatures/encrypted content that full blocks
636                // carry; mixing them into completed reasoning causes
637                // providers like Anthropic to reject with "signature required",
638                // so each part's text is kept aside, keyed by the stream
639                // correlator, until its completed block (if any) supersedes
640                // it. Only the provider-issued id may become the assembled
641                // block's durable id — the public correlator is rig-generated
642                // and must never enter history.
643                let index = self
644                    .reasoning_parts
645                    .iter()
646                    .position(|part| {
647                        part.correlator.as_deref() == Some(id.as_str())
648                            && matches!(part.state, ReasoningPartState::Pending(_))
649                    })
650                    .unwrap_or_else(|| {
651                        self.reasoning_parts.push(ReasoningPart {
652                            correlator: Some(id.clone()),
653                            provider_id: None,
654                            state: ReasoningPartState::Pending(String::new()),
655                        });
656                        self.reasoning_parts.len() - 1
657                    });
658                if let Some(part) = self.reasoning_parts.get_mut(index) {
659                    if let ReasoningPartState::Pending(text) = &mut part.state {
660                        text.push_str(reasoning);
661                    }
662                    if part.provider_id.is_none() {
663                        part.provider_id = provider_id.clone();
664                    }
665                }
666                Ok(vec![StreamedTurnEvent::EmitIngested])
667            }
668            StreamedAssistantContent::ToolCall {
669                tool_call,
670                internal_call_id,
671            } => {
672                if !self.allowed_tool_names.contains(&tool_call.function.name) {
673                    return Ok(self.surface_invalid_call(
674                        tool_call.clone(),
675                        internal_call_id.clone(),
676                        Some(json_utils::serialize_json_value(
677                            &tool_call.function.arguments,
678                        )),
679                        PendingInvalid::FullCall {
680                            tool_call: Box::new(tool_call.clone()),
681                            internal_call_id: internal_call_id.clone(),
682                        },
683                    ));
684                }
685
686                self.pending_tool_calls
687                    .push((tool_call.clone(), internal_call_id.clone()));
688                Ok(Vec::new())
689            }
690            StreamedAssistantContent::ToolCallDelta {
691                internal_call_id,
692                content,
693            } => {
694                let key = internal_call_id.clone();
695                match content {
696                    ToolCallDeltaContent::Name(name) => {
697                        if !self.allowed_tool_names.contains(name) {
698                            let buffered_args = self
699                                .delta_states
700                                .get(&key)
701                                .map(|state| state.buffered_arguments.join(""))
702                                .unwrap_or_default();
703                            let tool_call =
704                                self.name_delta_diagnostic_tool_call(name, &buffered_args);
705                            return Ok(self.surface_invalid_call(
706                                tool_call,
707                                internal_call_id.clone(),
708                                Some(buffered_args),
709                                PendingInvalid::NameDelta {
710                                    internal_call_id: internal_call_id.clone(),
711                                },
712                            ));
713                        }
714
715                        Ok(self.validate_delta_name(&key, name.clone()))
716                    }
717                    ToolCallDeltaContent::Delta(arguments) => {
718                        let state = self.delta_states.entry(key.clone()).or_default();
719                        if state.name_validated {
720                            Ok(vec![StreamedTurnEvent::EmitToolCallDelta {
721                                internal_call_id: internal_call_id.clone(),
722                                content: ToolCallDeltaContent::Delta(arguments.clone()),
723                            }])
724                        } else {
725                            state.buffered_arguments.push(arguments.clone());
726                            Ok(Vec::new())
727                        }
728                    }
729                }
730            }
731            StreamedAssistantContent::Final(final_response) => {
732                if let Some(err) = self.pending_delta_error() {
733                    return Err(err);
734                }
735
736                let usage = final_response.usage;
737                let emit_final = self.saw_text;
738                self.saw_text = false;
739                // `normalize_stream` has already reconciled this against the
740                // tool calls actually seen (see `StreamFinal::finish_reason`),
741                // so it is consumed as-is and never re-reconciled here.
742                let finish_reason = final_response.finish_reason.clone();
743                self.finish_reason = finish_reason.clone();
744                Ok(vec![StreamedTurnEvent::Completed {
745                    usage,
746                    emit_final,
747                    finish_reason,
748                }])
749            }
750            StreamedAssistantContent::Unknown(payload) => {
751                // Unmodeled provider item (e.g. a hosted-tool result): forward it
752                // to the consumer but do not fold it into the accumulated
753                // assistant message — there is no `AssistantContent::Unknown`, and
754                // it must not perturb text/tool-call/reasoning accumulation.
755                //
756                // The exclusion loses transcript content when the payload is
757                // rig assistant content (a replayed tagged block, not a
758                // stream-item shape). Counted here — text deltas arrive
759                // per-token, so per-item warns could flood the log — and
760                // surfaced as one warning at turn end; the payload itself
761                // stays redacted.
762                if unknown_payload_loses_assistant_content(payload.value()) {
763                    self.excluded_assistant_content.0 += 1;
764                    tracing::debug!(
765                        excluded = self.excluded_assistant_content.0,
766                        "stream item is a replayed assistant block, not a \
767                         stream-item shape; excluded from assembly"
768                    );
769                }
770                Ok(vec![StreamedTurnEvent::EmitIngested])
771            }
772        }
773    }
774
775    /// Apply the machine's resolution for the invalid tool call surfaced by
776    /// the last [`StreamedTurnEvent::InvalidToolCall`]. For a repaired name
777    /// this returns the deltas to forward (the repaired name plus any
778    /// buffered argument deltas).
779    pub fn resolve_pending_invalid(
780        &mut self,
781        resolution: &StreamedResolution,
782    ) -> Vec<StreamedTurnEvent> {
783        let Some(pending) = self.pending_invalid.take() else {
784            return Vec::new();
785        };
786
787        match (resolution, pending) {
788            (
789                StreamedResolution::Repaired { tool_name },
790                PendingInvalid::FullCall {
791                    mut tool_call,
792                    internal_call_id,
793                },
794            ) => {
795                tool_call.function.name = tool_name.clone();
796                self.pending_tool_calls.push((*tool_call, internal_call_id));
797                Vec::new()
798            }
799            (
800                StreamedResolution::Repaired { tool_name },
801                PendingInvalid::NameDelta { internal_call_id },
802            ) => self.validate_delta_name(&internal_call_id, tool_name.clone()),
803            (
804                StreamedResolution::TurnAbandoned { .. },
805                PendingInvalid::NameDelta { internal_call_id },
806            ) => {
807                // The abandoned call's buffered state must not trip the
808                // pending-delta consistency check while usage is drained.
809                self.delta_states.remove(&internal_call_id);
810                Vec::new()
811            }
812            (StreamedResolution::TurnAbandoned { .. }, PendingInvalid::FullCall { .. }) => {
813                Vec::new()
814            }
815        }
816    }
817
818    /// Error when argument deltas were buffered for a tool call whose name
819    /// never validated — a provider-stream consistency violation.
820    pub fn pending_delta_error(&self) -> Option<CompletionError> {
821        self.delta_states
822            .iter()
823            .find(|(_, state)| !state.name_validated && !state.buffered_arguments.is_empty())
824            .map(|(internal_call_id, state)| {
825                CompletionError::ResponseError(format!(
826                    "streamed tool call arguments received before a validated tool name for internal_call_id `{internal_call_id}` ({} buffered argument delta(s))",
827                    state.buffered_arguments.len()
828                ))
829            })
830    }
831
832    /// Snapshot of the turn so far, for diagnostics and rollback messages.
833    pub fn partial_turn(&self, message_id: Option<String>) -> PartialStreamedTurn {
834        let reasoning = self.assembled_reasoning();
835
836        PartialStreamedTurn {
837            message_id,
838            text: self.saw_text.then(|| self.text.clone()),
839            reasoning,
840            pending_tool_calls: self
841                .pending_tool_calls
842                .iter()
843                .map(|(tool_call, _)| tool_call.clone())
844                .collect(),
845        }
846    }
847
848    /// Assemble the completed turn. `final_choice` is the provider's
849    /// aggregated choice for the turn
850    /// ([`crate::streaming::StreamingCompletionResponse::choice`]).
851    pub fn finish(
852        mut self,
853        message_id: Option<String>,
854        final_choice: &[AssistantContent],
855    ) -> StreamedTurn {
856        let reasoning = self.drain_reasoning();
857        let choice = self.canonical_choice_with(reasoning, final_choice);
858        let internal_call_ids: Vec<(String, String)> = self
859            .pending_tool_calls
860            .iter()
861            .map(|(tool_call, internal_call_id)| {
862                (tool_call.id.as_str().to_owned(), internal_call_id.clone())
863            })
864            .collect();
865
866        StreamedTurn {
867            message_id,
868            choice,
869            executable_tool_names: self.executable_tool_names,
870            allowed_tool_names: self.allowed_tool_names,
871            internal_call_ids,
872            finish_reason: self.finish_reason.take(),
873        }
874    }
875
876    /// Park resolution on `pending` and surface the rejected call to the
877    /// caller as an [`StreamedTurnEvent::InvalidToolCall`].
878    fn surface_invalid_call(
879        &mut self,
880        tool_call: ToolCall,
881        internal_call_id: String,
882        args: Option<String>,
883        pending: PendingInvalid,
884    ) -> Vec<StreamedTurnEvent> {
885        let invalid = StreamedInvalidToolCall {
886            tool_call,
887            internal_call_id,
888            args,
889            executable_tool_names: self.executable_tool_names.clone(),
890            allowed_tool_names: self.allowed_tool_names.clone(),
891        };
892        self.pending_invalid = Some(pending);
893        vec![StreamedTurnEvent::InvalidToolCall(Box::new(invalid))]
894    }
895
896    fn name_delta_diagnostic_tool_call(&self, name: &str, buffered_args: &str) -> ToolCall {
897        let diagnostic_args = if buffered_args.trim().is_empty() {
898            serde_json::Value::Null
899        } else {
900            serde_json::from_str(buffered_args).unwrap_or(serde_json::Value::Null)
901        };
902        // Diagnostic only: the durable provider id is unknown at delta
903        // time, and no stream-internal key may surface — so the call mints
904        // its correlation handle and `provider` stays `None` (hooks
905        // faithfully observe that no provider id exists). The same minted
906        // id correlates the retry transcript pair in `rollback_messages`.
907        ToolCall::new(
908            rig_core::message::ToolCallId::mint(),
909            ToolFunction::new(name.to_string(), diagnostic_args),
910        )
911    }
912
913    fn validate_delta_name(&mut self, key: &str, name: String) -> Vec<StreamedTurnEvent> {
914        let state = self.delta_states.entry(key.to_owned()).or_default();
915        state.name_validated = true;
916        let buffered_arguments = std::mem::take(&mut state.buffered_arguments);
917
918        let mut events = vec![StreamedTurnEvent::EmitToolCallDelta {
919            internal_call_id: key.to_owned(),
920            content: ToolCallDeltaContent::Name(name),
921        }];
922        events.extend(buffered_arguments.into_iter().map(|arguments| {
923            StreamedTurnEvent::EmitToolCallDelta {
924                internal_call_id: key.to_owned(),
925                content: ToolCallDeltaContent::Delta(arguments),
926            }
927        }));
928        events
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935    use crate::agent::hook::InvalidToolCallAction;
936    use crate::agent::run::{AgentRun, AgentRunStep};
937    use crate::completion::PromptError;
938    use crate::test_utils::mock_final;
939    use rig_core::message::{Text, ToolResultContent, UserContent};
940    use serde_json::json;
941
942    fn tool_names(names: &[&str]) -> BTreeSet<String> {
943        names.iter().map(|name| (*name).to_string()).collect()
944    }
945
946    fn assembler() -> StreamedTurnAssembler {
947        StreamedTurnAssembler::new(tool_names(&["add"]), tool_names(&["add"]))
948    }
949
950    fn text_item(text: &str) -> StreamedAssistantContent {
951        StreamedAssistantContent::Text(Text::new(text.to_string()))
952    }
953
954    fn tool_call(id: &str, name: &str) -> ToolCall {
955        // The provider-boundary shape: the wire id becomes both the durable
956        // id and the provider correlator.
957        ToolCall::from_wire(id, ToolFunction::new(name.to_string(), json!({"x": 1})))
958    }
959
960    fn tool_call_item(id: &str, name: &str) -> StreamedAssistantContent {
961        StreamedAssistantContent::ToolCall {
962            tool_call: tool_call(id, name),
963            internal_call_id: format!("internal_{id}"),
964        }
965    }
966
967    fn final_item() -> StreamedAssistantContent {
968        StreamedAssistantContent::Final(mock_final(Usage::new()))
969    }
970
971    fn name_delta(id: &str, name: &str) -> StreamedAssistantContent {
972        StreamedAssistantContent::ToolCallDelta {
973            internal_call_id: format!("internal_{id}"),
974            content: ToolCallDeltaContent::Name(name.to_string()),
975        }
976    }
977
978    fn args_delta(id: &str, arguments: &str) -> StreamedAssistantContent {
979        StreamedAssistantContent::ToolCallDelta {
980            internal_call_id: format!("internal_{id}"),
981            content: ToolCallDeltaContent::Delta(arguments.to_string()),
982        }
983    }
984
985    fn expect_invalid(events: Vec<StreamedTurnEvent>) -> StreamedInvalidToolCall {
986        match events.into_iter().next() {
987            Some(StreamedTurnEvent::InvalidToolCall(invalid)) => *invalid,
988            other => panic!("expected InvalidToolCall, got {other:?}"),
989        }
990    }
991
992    #[test]
993    fn text_accumulates_and_emits() {
994        let mut asm = assembler();
995        let events = asm
996            .ingest(&text_item("hel"))
997            .expect("ingest should succeed");
998        assert!(matches!(
999            events.as_slice(),
1000            [StreamedTurnEvent::EmitIngested]
1001        ));
1002        asm.ingest(&text_item("lo")).expect("ingest should succeed");
1003        assert_eq!(asm.aggregated_text(), "hello");
1004    }
1005
1006    #[test]
1007    fn unknown_item_emits_to_consumer_without_touching_accumulation() {
1008        let mut asm = assembler();
1009        asm.ingest(&text_item("answer"))
1010            .expect("ingest text should succeed");
1011
1012        let events = asm
1013            .ingest(&StreamedAssistantContent::Unknown(
1014                json!({ "type": "web_search_call", "id": "ws_1" }).into(),
1015            ))
1016            .expect("ingest unknown should succeed");
1017
1018        // The unmodeled item is forwarded to the consumer ...
1019        assert!(matches!(
1020            events.as_slice(),
1021            [StreamedTurnEvent::EmitIngested]
1022        ));
1023        // ... but perturbs no accumulation state used to build the assistant message.
1024        assert_eq!(asm.aggregated_text(), "answer");
1025    }
1026
1027    /// The decode-outcome contract, as a total matrix: every stream-item
1028    /// payload has exactly one of three outcomes — assembled,
1029    /// excluded-and-counted (one warning at turn end), or excluded-quiet
1030    /// (provider-native unmodeled) — and no shape is silent. `expected` is a
1031    /// wildcard-free match, so a new shape class cannot compile without a
1032    /// mandated outcome, and the coverage assert below fails until it also
1033    /// has a fixture.
1034    #[derive(Debug, Clone, Copy, PartialEq)]
1035    enum ShapeClass {
1036        WellFormedText,
1037        UnknownKeyedText,
1038        TaggedText,
1039        TaggedRigBlock,
1040        MalformedParamsText,
1041        /// A provider-native frame that happens to carry a string `text`
1042        /// key (e.g. an annotation event). Tolerance folds its text into
1043        /// the message — the documented noise tradeoff: never losing real
1044        /// text outranks occasionally ingesting a frame's caption.
1045        ProviderNativeTextCarrying,
1046        ProviderNativeUnmodeled,
1047    }
1048
1049    #[derive(Debug, PartialEq)]
1050    enum ExpectedOutcome {
1051        Assembled { text: &'static str },
1052        ExcludedAndCounted,
1053        ExcludedQuiet,
1054    }
1055
1056    /// The matrix's outcome column. No wildcard arm — the compiler is the
1057    /// missing-cell error.
1058    fn expected(shape: ShapeClass) -> ExpectedOutcome {
1059        match shape {
1060            ShapeClass::WellFormedText
1061            | ShapeClass::UnknownKeyedText
1062            | ShapeClass::TaggedText
1063            | ShapeClass::ProviderNativeTextCarrying => ExpectedOutcome::Assembled { text: "hi" },
1064            ShapeClass::TaggedRigBlock | ShapeClass::MalformedParamsText => {
1065                ExpectedOutcome::ExcludedAndCounted
1066            }
1067            ShapeClass::ProviderNativeUnmodeled => ExpectedOutcome::ExcludedQuiet,
1068        }
1069    }
1070
1071    /// The matrix's fixture rows. Every shape class appears at least once
1072    /// (pinned by the coverage assert in the test); classes with several
1073    /// wire spellings carry one fixture per spelling.
1074    fn decode_matrix_cases() -> Vec<(ShapeClass, serde_json::Value)> {
1075        vec![
1076            (ShapeClass::WellFormedText, json!({"text": "hi"})),
1077            (
1078                ShapeClass::UnknownKeyedText,
1079                json!({"text": "hi", "citations": ["stray"], "future": 1}),
1080            ),
1081            (
1082                ShapeClass::TaggedText,
1083                json!({"type": "text", "text": "hi"}),
1084            ),
1085            (
1086                ShapeClass::TaggedRigBlock,
1087                json!({"type": "toolcall", "id": "call_1",
1088                       "function": {"name": "add", "arguments": {}}}),
1089            ),
1090            (
1091                ShapeClass::TaggedRigBlock,
1092                json!({"type": "reasoning", "id": null, "content": []}),
1093            ),
1094            (
1095                ShapeClass::TaggedRigBlock,
1096                json!({"type": "image", "data": {"type": "base64", "value": "aGk="}}),
1097            ),
1098            (
1099                ShapeClass::MalformedParamsText,
1100                json!({"text": "hi", "additional_params": []}),
1101            ),
1102            (
1103                ShapeClass::MalformedParamsText,
1104                json!({"type": "text", "text": "hi", "additional_params": []}),
1105            ),
1106            (
1107                ShapeClass::ProviderNativeUnmodeled,
1108                json!({"type": "web_search_call", "id": "ws_1"}),
1109            ),
1110            (
1111                ShapeClass::ProviderNativeTextCarrying,
1112                json!({"type": "output_text.annotation", "text": "hi"}),
1113            ),
1114            (ShapeClass::ProviderNativeUnmodeled, json!({"text": 42})),
1115        ]
1116    }
1117
1118    #[test]
1119    fn decode_outcome_matrix_is_total_and_no_shape_is_silent() {
1120        let cases = decode_matrix_cases();
1121        // Vacuity floor: an emptied fixture table must fail loudly, not
1122        // pass by checking nothing.
1123        assert!(!cases.is_empty(), "decode_matrix_cases returned no rows");
1124        // Coverage: every shape class has at least one fixture. Extend
1125        // `witnesses` (and `decode_matrix_cases`) when adding a variant —
1126        // `expected` already refuses to compile without a classification.
1127        let witnesses = [
1128            ShapeClass::WellFormedText,
1129            ShapeClass::UnknownKeyedText,
1130            ShapeClass::TaggedText,
1131            ShapeClass::TaggedRigBlock,
1132            ShapeClass::MalformedParamsText,
1133            ShapeClass::ProviderNativeTextCarrying,
1134            ShapeClass::ProviderNativeUnmodeled,
1135        ];
1136        for shape in witnesses {
1137            assert!(
1138                cases.iter().any(|(case_shape, _)| *case_shape == shape),
1139                "no fixture for {shape:?} — add a row to decode_matrix_cases"
1140            );
1141        }
1142
1143        for (shape, payload) in cases {
1144            let item = serde_json::from_value::<StreamedAssistantContent>(payload.clone())
1145                .expect("stream-item decode is tolerant and must not fail");
1146            let mut asm = assembler();
1147            match expected(shape) {
1148                ExpectedOutcome::Assembled { text } => {
1149                    assert!(
1150                        matches!(&item, StreamedAssistantContent::Text(t) if t.text == text),
1151                        "{shape:?} must decode as stream text: {payload}"
1152                    );
1153                    asm.ingest(&item).expect("ingest");
1154                    assert_eq!(asm.aggregated_text(), text, "{shape:?}: {payload}");
1155                    assert_eq!(
1156                        asm.excluded_assistant_content(),
1157                        0,
1158                        "{shape:?} must not count as excluded: {payload}"
1159                    );
1160                }
1161                ExpectedOutcome::ExcludedAndCounted => {
1162                    assert!(
1163                        matches!(&item, StreamedAssistantContent::Unknown(_)),
1164                        "{shape:?} must decode Unknown: {payload}"
1165                    );
1166                    asm.ingest(&item).expect("ingest");
1167                    assert_eq!(asm.aggregated_text(), "", "{shape:?}: {payload}");
1168                    assert_eq!(
1169                        asm.excluded_assistant_content(),
1170                        1,
1171                        "{shape:?} loses assistant content and must be counted: {payload}"
1172                    );
1173                }
1174                ExpectedOutcome::ExcludedQuiet => {
1175                    assert!(
1176                        matches!(&item, StreamedAssistantContent::Unknown(_)),
1177                        "{shape:?} must decode Unknown: {payload}"
1178                    );
1179                    asm.ingest(&item).expect("ingest");
1180                    assert_eq!(asm.aggregated_text(), "", "{shape:?}: {payload}");
1181                    assert_eq!(
1182                        asm.excluded_assistant_content(),
1183                        0,
1184                        "{shape:?} is provider-native and must stay quiet: {payload}"
1185                    );
1186                }
1187            }
1188        }
1189    }
1190    #[test]
1191    fn choice_text_items_judge_annotation_by_presence() {
1192        // `AdditionalParams` is non-empty by construction — an empty carrier
1193        // is unrepresentable (`try_from_value(json!({}))` yields `None`) —
1194        // so plain `is_some()` is the whole annotation rule and live and
1195        // restored classification agree by type.
1196        let unannotated = AssistantContent::Text(Text {
1197            text: String::new(),
1198            additional_params: rig_core::message::AdditionalParams::try_from_value(json!({}))
1199                .expect("object params"),
1200        });
1201        assert!(assistant_text_items_from_choice(&[unannotated]).is_empty());
1202
1203        // A genuinely annotated empty block is content and survives.
1204        let annotated = AssistantContent::Text(Text {
1205            text: String::new(),
1206            additional_params: rig_core::message::AdditionalParams::try_from_value(
1207                json!({"citations": [1]}),
1208            )
1209            .expect("object params"),
1210        });
1211        assert_eq!(assistant_text_items_from_choice(&[annotated]).len(), 1);
1212    }
1213
1214    #[test]
1215    fn argument_deltas_buffer_until_name_validates() {
1216        let mut asm = assembler();
1217
1218        let events = asm
1219            .ingest(&args_delta("tc_1", "{\"x\""))
1220            .expect("ingest should succeed");
1221        assert!(events.is_empty(), "arguments must buffer before the name");
1222
1223        let events = asm
1224            .ingest(&name_delta("tc_1", "add"))
1225            .expect("ingest should succeed");
1226        let contents: Vec<_> = events
1227            .iter()
1228            .map(|event| match event {
1229                StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
1230                other => panic!("expected EmitToolCallDelta, got {other:?}"),
1231            })
1232            .collect();
1233        assert_eq!(
1234            contents,
1235            vec![
1236                ToolCallDeltaContent::Name("add".to_string()),
1237                ToolCallDeltaContent::Delta("{\"x\"".to_string()),
1238            ]
1239        );
1240
1241        // Subsequent argument deltas now pass straight through.
1242        let events = asm
1243            .ingest(&args_delta("tc_1", ":1}"))
1244            .expect("ingest should succeed");
1245        assert_eq!(events.len(), 1);
1246    }
1247
1248    #[test]
1249    fn buffered_arguments_without_validated_name_error_at_final() {
1250        let mut asm = assembler();
1251        asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
1252            .expect("ingest should succeed");
1253
1254        assert!(asm.pending_delta_error().is_some());
1255        assert!(asm.ingest(&final_item()).is_err());
1256    }
1257
1258    #[test]
1259    fn finish_orders_reasoning_text_then_tool_calls() {
1260        let mut asm = assembler();
1261        asm.ingest(&StreamedAssistantContent::ReasoningDelta {
1262            id: "corr_1".to_string(),
1263            provider_id: Some("rs_1".to_string()),
1264            reasoning: "think".to_string(),
1265        })
1266        .expect("ingest should succeed");
1267        asm.ingest(&tool_call_item("tc_1", "add"))
1268            .expect("ingest should succeed");
1269
1270        // Provider aggregation order differs deliberately.
1271        let final_choice = vec![
1272            AssistantContent::text("answer"),
1273            AssistantContent::ToolCall(tool_call("tc_1", "add")),
1274        ];
1275
1276        let turn = asm.finish(Some("msg_1".to_string()), &final_choice);
1277        let kinds: Vec<&'static str> = turn
1278            .choice
1279            .iter()
1280            .map(|item| match item {
1281                AssistantContent::Reasoning(_) => "reasoning",
1282                AssistantContent::Text(_) => "text",
1283                AssistantContent::ToolCall(_) => "tool_call",
1284                _ => "other",
1285            })
1286            .collect();
1287        assert_eq!(kinds, vec!["reasoning", "text", "tool_call"]);
1288    }
1289
1290    fn reasoning_delta(
1291        correlator: &str,
1292        provider_id: Option<&str>,
1293        text: &str,
1294    ) -> StreamedAssistantContent {
1295        StreamedAssistantContent::ReasoningDelta {
1296            id: correlator.to_string(),
1297            provider_id: provider_id.map(str::to_string),
1298            reasoning: text.to_string(),
1299        }
1300    }
1301
1302    fn completed_reasoning(
1303        correlator: &str,
1304        provider_id: Option<&str>,
1305        text: &str,
1306        signature: Option<&str>,
1307    ) -> StreamedAssistantContent {
1308        let mut reasoning = Reasoning::new_with_signature(text, signature.map(str::to_string));
1309        if let Some(provider_id) = provider_id {
1310            reasoning = reasoning.with_id(provider_id.to_string());
1311        }
1312        StreamedAssistantContent::Reasoning {
1313            reasoning,
1314            id: correlator.to_string(),
1315        }
1316    }
1317
1318    fn assembled_reasoning_of(asm: &StreamedTurnAssembler) -> Vec<Reasoning> {
1319        asm.partial_turn(None).reasoning
1320    }
1321
1322    #[test]
1323    fn aggregated_reasoning_delta_is_scoped_to_each_interleaved_part() {
1324        let mut asm = assembler();
1325        asm.ingest(&reasoning_delta("corr_a", None, "first "))
1326            .expect("ingest");
1327        assert_eq!(asm.aggregated_reasoning("corr_a"), Some("first "));
1328
1329        asm.ingest(&reasoning_delta("corr_b", Some("rs_b"), "second"))
1330            .expect("ingest");
1331        assert_eq!(asm.aggregated_reasoning("corr_b"), Some("second"));
1332
1333        asm.ingest(&reasoning_delta("corr_a", Some("rs_a"), "part"))
1334            .expect("ingest");
1335        assert_eq!(asm.aggregated_reasoning("corr_a"), Some("first part"));
1336        assert_eq!(asm.aggregated_reasoning("corr_b"), Some("second"));
1337        assert_eq!(asm.aggregated_reasoning("missing"), None);
1338
1339        let reasoning = assembled_reasoning_of(&asm);
1340        assert_eq!(reasoning[0].id.as_deref(), Some("rs_a"));
1341        assert_eq!(reasoning[1].id.as_deref(), Some("rs_b"));
1342    }
1343
1344    #[test]
1345    fn aggregated_reasoning_delta_uses_a_new_pending_part_after_completion() {
1346        let mut asm = assembler();
1347        asm.ingest(&reasoning_delta("corr_a", Some("rs_a"), "old"))
1348            .expect("ingest");
1349        asm.ingest(&completed_reasoning(
1350            "corr_a",
1351            Some("rs_a"),
1352            "old",
1353            Some("sig"),
1354        ))
1355        .expect("ingest");
1356        assert_eq!(asm.aggregated_reasoning("corr_a"), None);
1357
1358        asm.ingest(&reasoning_delta("corr_a", Some("rs_new"), "new"))
1359            .expect("ingest");
1360        assert_eq!(asm.aggregated_reasoning("corr_a"), Some("new"));
1361    }
1362
1363    #[test]
1364    fn interleaved_delta_parts_stay_distinct_in_arrival_order() {
1365        let mut asm = assembler();
1366        asm.ingest(&reasoning_delta("corr_a", None, "first "))
1367            .expect("ingest");
1368        asm.ingest(&reasoning_delta("corr_a", None, "part"))
1369            .expect("ingest");
1370        asm.ingest(&tool_call_item("tc_1", "add")).expect("ingest");
1371        asm.ingest(&reasoning_delta("corr_b", None, "second part"))
1372            .expect("ingest");
1373
1374        let reasoning = assembled_reasoning_of(&asm);
1375        assert_eq!(
1376            reasoning.len(),
1377            2,
1378            "two parts must not merge: {reasoning:?}"
1379        );
1380        assert!(matches!(
1381            reasoning[0].content.first(),
1382            Some(rig_core::message::ReasoningContent::Text { text, .. }) if text == "first part"
1383        ));
1384        assert!(matches!(
1385            reasoning[1].content.first(),
1386            Some(rig_core::message::ReasoningContent::Text { text, .. }) if text == "second part"
1387        ));
1388    }
1389
1390    #[test]
1391    fn delta_only_part_survives_alongside_a_completed_block() {
1392        // The openrouter shape: visible chain-of-thought streams as deltas
1393        // whose synthesized end stays silent, while an encrypted block
1394        // arrives completed. Both must reach history, deltas first.
1395        let mut asm = assembler();
1396        asm.ingest(&reasoning_delta("corr_cot", None, "visible thoughts"))
1397            .expect("ingest");
1398        asm.ingest(&completed_reasoning(
1399            "corr_enc",
1400            Some("rd_1"),
1401            "encrypted payload",
1402            Some("sig"),
1403        ))
1404        .expect("ingest");
1405
1406        let reasoning = assembled_reasoning_of(&asm);
1407        assert_eq!(
1408            reasoning.len(),
1409            2,
1410            "the visible chain of thought must not be dropped: {reasoning:?}"
1411        );
1412        assert!(matches!(
1413            reasoning[0].content.first(),
1414            Some(rig_core::message::ReasoningContent::Text { text, .. })
1415                if text == "visible thoughts"
1416        ));
1417        assert_eq!(reasoning[0].id, None);
1418        assert_eq!(reasoning[1].id.as_deref(), Some("rd_1"));
1419    }
1420
1421    /// A later completion restating the SAME correlator is the same part's
1422    /// authoritative whole (the unsigned-close-then-signed-restatement
1423    /// shape): it replaces the completed slot, never appends a duplicate.
1424    #[test]
1425    fn a_same_correlator_completion_replaces_the_completed_part() {
1426        let mut asm = assembler();
1427        asm.ingest(&reasoning_delta("corr_a", None, "think"))
1428            .expect("ingest");
1429        asm.ingest(&completed_reasoning("corr_a", None, "think", None))
1430            .expect("ingest");
1431        asm.ingest(&completed_reasoning("corr_a", None, "think", Some("sig")))
1432            .expect("ingest");
1433
1434        let reasoning = assembled_reasoning_of(&asm);
1435        assert_eq!(
1436            reasoning.len(),
1437            1,
1438            "one part per correlator, signed restatement replaces: {reasoning:?}"
1439        );
1440        assert!(matches!(
1441            reasoning[0].content.first(),
1442            Some(rig_core::message::ReasoningContent::Text { text, signature: Some(sig) })
1443                if text == "think" && sig == "sig"
1444        ));
1445    }
1446
1447    /// Same shape with a provider id: the exact-correlator match must win
1448    /// BEFORE the shared-provider-id extend fallback, or the signed
1449    /// restatement doubles its own text.
1450    #[test]
1451    fn a_same_correlator_completion_with_a_provider_id_does_not_double_extend() {
1452        let mut asm = assembler();
1453        asm.ingest(&reasoning_delta("corr_a", Some("rs_1"), "think"))
1454            .expect("ingest");
1455        asm.ingest(&completed_reasoning("corr_a", Some("rs_1"), "think", None))
1456            .expect("ingest");
1457        asm.ingest(&completed_reasoning(
1458            "corr_a",
1459            Some("rs_1"),
1460            "think",
1461            Some("sig"),
1462        ))
1463        .expect("ingest");
1464
1465        let reasoning = assembled_reasoning_of(&asm);
1466        assert_eq!(reasoning.len(), 1, "{reasoning:?}");
1467        assert_eq!(
1468            reasoning[0].content.len(),
1469            1,
1470            "the restatement must replace, not extend: {reasoning:?}"
1471        );
1472    }
1473
1474    #[test]
1475    fn completed_block_supersedes_its_deltas_by_correlator() {
1476        let mut asm = assembler();
1477        asm.ingest(&reasoning_delta("corr_a", None, "streamed text"))
1478            .expect("ingest");
1479        asm.ingest(&completed_reasoning(
1480            "corr_a",
1481            None,
1482            "streamed text",
1483            Some("sig_1"),
1484        ))
1485        .expect("ingest");
1486
1487        let reasoning = assembled_reasoning_of(&asm);
1488        assert_eq!(
1489            reasoning.len(),
1490            1,
1491            "the completed block replaces its own deltas: {reasoning:?}"
1492        );
1493        assert!(matches!(
1494            reasoning[0].content.first(),
1495            Some(rig_core::message::ReasoningContent::Text { text, signature: Some(sig) })
1496                if text == "streamed text" && sig == "sig_1"
1497        ));
1498    }
1499
1500    #[test]
1501    fn completed_block_supersedes_its_deltas_by_provider_id() {
1502        let mut asm = assembler();
1503        asm.ingest(&reasoning_delta("corr_a", Some("rs_1"), "streamed text"))
1504            .expect("ingest");
1505        // A completed restatement whose correlator does not match (e.g. a
1506        // whole-block event minted its own) still supersedes via the
1507        // durable provider handle.
1508        asm.ingest(&completed_reasoning(
1509            "corr_other",
1510            Some("rs_1"),
1511            "restated text",
1512            None,
1513        ))
1514        .expect("ingest");
1515
1516        let reasoning = assembled_reasoning_of(&asm);
1517        assert_eq!(reasoning.len(), 1, "{reasoning:?}");
1518        assert!(matches!(
1519            reasoning[0].content.first(),
1520            Some(rig_core::message::ReasoningContent::Text { text, .. }) if text == "restated text"
1521        ));
1522    }
1523
1524    #[test]
1525    fn completed_blocks_sharing_a_provider_id_extend_one_part() {
1526        let mut asm = assembler();
1527        asm.ingest(&completed_reasoning(
1528            "corr_1",
1529            Some("rs_1"),
1530            "step-1",
1531            Some("sig-1"),
1532        ))
1533        .expect("ingest");
1534        asm.ingest(&completed_reasoning(
1535            "corr_2",
1536            Some("rs_1"),
1537            "step-2",
1538            Some("sig-2"),
1539        ))
1540        .expect("ingest");
1541        asm.ingest(&completed_reasoning("corr_3", Some("rs_2"), "other", None))
1542            .expect("ingest");
1543
1544        let reasoning = assembled_reasoning_of(&asm);
1545        assert_eq!(reasoning.len(), 2, "{reasoning:?}");
1546        assert_eq!(reasoning[0].id.as_deref(), Some("rs_1"));
1547        assert_eq!(reasoning[0].content.len(), 2);
1548        assert_eq!(reasoning[1].id.as_deref(), Some("rs_2"));
1549    }
1550
1551    #[test]
1552    fn completed_blocks_without_ids_stay_separate_parts() {
1553        let mut asm = assembler();
1554        asm.ingest(&completed_reasoning("corr_1", None, "first", None))
1555            .expect("ingest");
1556        asm.ingest(&completed_reasoning("corr_2", None, "second", None))
1557            .expect("ingest");
1558
1559        let reasoning = assembled_reasoning_of(&asm);
1560        assert_eq!(
1561            reasoning.len(),
1562            2,
1563            "id-less blocks never merge: {reasoning:?}"
1564        );
1565    }
1566
1567    #[test]
1568    fn each_delta_part_keeps_its_own_provider_id() {
1569        let mut asm = assembler();
1570        asm.ingest(&reasoning_delta("corr_a", Some("rs_a"), "alpha"))
1571            .expect("ingest");
1572        asm.ingest(&reasoning_delta("corr_b", Some("rs_b"), "beta"))
1573            .expect("ingest");
1574
1575        let reasoning = assembled_reasoning_of(&asm);
1576        assert_eq!(reasoning.len(), 2, "{reasoning:?}");
1577        assert_eq!(reasoning[0].id.as_deref(), Some("rs_a"));
1578        assert_eq!(reasoning[1].id.as_deref(), Some("rs_b"));
1579    }
1580
1581    #[test]
1582    fn canonical_choice_and_partial_turn_agree_on_multi_part_reasoning() {
1583        let mut asm = assembler();
1584        asm.ingest(&reasoning_delta("corr_a", None, "visible"))
1585            .expect("ingest");
1586        asm.ingest(&completed_reasoning(
1587            "corr_b",
1588            Some("rd_1"),
1589            "enc",
1590            Some("sig"),
1591        ))
1592        .expect("ingest");
1593
1594        let partial = asm.partial_turn(None).reasoning;
1595        let final_choice = vec![AssistantContent::text("")];
1596        let turn = asm.finish(None, &final_choice);
1597        let finished: Vec<Reasoning> = turn
1598            .choice
1599            .iter()
1600            .filter_map(|content| match content {
1601                AssistantContent::Reasoning(reasoning) => Some(reasoning.clone()),
1602                _ => None,
1603            })
1604            .collect();
1605        assert_eq!(partial, finished, "partial and finished assembly agree");
1606        assert_eq!(finished.len(), 2);
1607    }
1608
1609    #[test]
1610    fn finish_passes_raw_choice_through_for_plain_text_turns() {
1611        let mut asm = assembler();
1612        asm.ingest(&text_item("hi")).expect("ingest should succeed");
1613
1614        let final_choice = vec![AssistantContent::text("hi")];
1615        let turn = asm.finish(None, &final_choice);
1616        assert_eq!(
1617            serde_json::to_value(&turn.choice).expect("serialize"),
1618            serde_json::to_value(&final_choice).expect("serialize"),
1619        );
1620    }
1621
1622    #[test]
1623    fn streamed_run_completes_a_tool_roundtrip() {
1624        let mut run = AgentRun::new("add things").max_turns(2);
1625
1626        // Turn 1: the model streams one tool call.
1627        let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
1628            panic!("expected CallModel");
1629        };
1630        let mut asm = assembler();
1631        assert!(
1632            asm.ingest(&tool_call_item("tc_1", "add"))
1633                .expect("ingest should succeed")
1634                .is_empty()
1635        );
1636        let usage = Usage {
1637            input_tokens: 5,
1638            output_tokens: 7,
1639            total_tokens: 12,
1640            ..Usage::new()
1641        };
1642        run.record_streamed_completion_call(
1643            usage,
1644            rig_core::completion::ResponseIdentity::default(),
1645            None,
1646            serde_json::Value::Null,
1647        )
1648        .expect("record should succeed");
1649        let final_choice = vec![AssistantContent::ToolCall(tool_call("tc_1", "add"))];
1650        run.streamed_turn(asm.finish(Some("msg_1".to_string()), &final_choice))
1651            .expect("streamed_turn should succeed");
1652
1653        let AgentRunStep::CallTools { calls } = run.next_step().expect("next_step") else {
1654            panic!("expected CallTools");
1655        };
1656        assert_eq!(calls.len(), 1);
1657        assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_tc_1"));
1658        run.tool_results(vec![UserContent::tool_result(
1659            "tc_1",
1660            "add",
1661            vec![ToolResultContent::text("2")],
1662        )])
1663        .expect("tool_results should succeed");
1664
1665        // Turn 2: plain text finishes the run.
1666        let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
1667            panic!("expected CallModel");
1668        };
1669        let asm = assembler();
1670        run.record_streamed_completion_call(
1671            Usage::new(),
1672            rig_core::completion::ResponseIdentity::default(),
1673            None,
1674            serde_json::Value::Null,
1675        )
1676        .expect("record should succeed");
1677        let final_choice = vec![AssistantContent::text("done")];
1678        run.streamed_turn(asm.finish(None, &final_choice))
1679            .expect("streamed_turn should succeed");
1680
1681        let AgentRunStep::Done(response) = run.next_step().expect("next_step") else {
1682            panic!("expected Done");
1683        };
1684        assert_eq!(response.output, "done");
1685        assert_eq!(response.usage, usage);
1686        assert_eq!(response.completion_calls.len(), 2);
1687        assert_eq!(response.completion_calls[0].usage, usage);
1688        assert_eq!(response.completion_calls[1].usage, Usage::new());
1689        // prompt, assistant tool call, tool result, final assistant text
1690        assert_eq!(
1691            response
1692                .messages
1693                .expect("messages should be recorded")
1694                .len(),
1695            4
1696        );
1697    }
1698
1699    #[test]
1700    fn streamed_invalid_tool_call_retry_rolls_back_with_partial_turn() {
1701        let mut run = AgentRun::new("use the tool")
1702            .max_turns(2)
1703            .max_invalid_tool_call_retries(1);
1704        run.next_step().expect("next_step");
1705
1706        let mut asm = assembler();
1707        asm.ingest(&text_item("thinking ")).expect("ingest");
1708        let invalid = expect_invalid(
1709            asm.ingest(&tool_call_item("tc_1", "default_api"))
1710                .expect("ingest should succeed"),
1711        );
1712        let partial = asm.partial_turn(Some("msg_1".to_string()));
1713        assert_eq!(partial.text.as_deref(), Some("thinking "));
1714
1715        let context = run.streamed_invalid_tool_call_context(&partial, &invalid);
1716        assert!(context.is_streaming);
1717        assert_eq!(context.tool_name, "default_api");
1718        assert_eq!(context.internal_call_id.as_deref(), Some("internal_tc_1"));
1719
1720        let resolution = run
1721            .resolve_streamed_invalid_tool_call(
1722                &partial,
1723                &invalid,
1724                InvalidToolCallAction::retry("use add instead"),
1725            )
1726            .expect("retry should be accepted");
1727        assert!(matches!(
1728            resolution,
1729            StreamedResolution::TurnAbandoned {
1730                skipped_tool_result: None
1731            }
1732        ));
1733        asm.resolve_pending_invalid(&resolution);
1734
1735        // Usage from the drained stream is recorded after the rollback.
1736        run.record_streamed_completion_call(
1737            Usage::new(),
1738            rig_core::completion::ResponseIdentity::default(),
1739            None,
1740            serde_json::Value::Null,
1741        )
1742        .expect("record after rollback should succeed");
1743
1744        // The rollback appended the partial assistant turn and feedback.
1745        assert_eq!(run.messages().len(), 3);
1746        let AgentRunStep::CallModel { turn, .. } = run.next_step().expect("next_step") else {
1747            panic!("expected CallModel retry");
1748        };
1749        assert_eq!(turn, 2);
1750    }
1751
1752    #[test]
1753    fn streamed_invalid_tool_call_stop_leaves_run_terminal() {
1754        let mut run = AgentRun::new("use the tool");
1755        run.next_step().expect("next_step");
1756
1757        let mut asm = assembler();
1758        let invalid = expect_invalid(
1759            asm.ingest(&tool_call_item("tc_1", "default_api"))
1760                .expect("ingest should succeed"),
1761        );
1762        let partial = asm.partial_turn(Some("msg_1".to_string()));
1763
1764        let err = run
1765            .resolve_streamed_invalid_tool_call(
1766                &partial,
1767                &invalid,
1768                InvalidToolCallAction::stop("operator stop"),
1769            )
1770            .expect_err("stop should cancel the run");
1771        assert!(matches!(
1772            err,
1773            PromptError::PromptCancelled { reason, .. } if reason == "operator stop"
1774        ));
1775
1776        let err = run
1777            .next_step()
1778            .expect_err("a stopped streamed run must remain terminal");
1779        assert!(matches!(
1780            err,
1781            PromptError::PromptCancelled { reason, .. }
1782                if reason.contains("next_step called after the run already failed")
1783        ));
1784    }
1785
1786    #[test]
1787    fn streamed_invalid_tool_call_retry_cannot_emit_call_past_total_budget() {
1788        let mut run = AgentRun::new("use the tool")
1789            .max_turns(1)
1790            .max_invalid_tool_call_retries(1);
1791        run.next_step().expect("initial model call");
1792
1793        let mut asm = assembler();
1794        let invalid = expect_invalid(
1795            asm.ingest(&tool_call_item("tc_1", "default_api"))
1796                .expect("ingest should succeed"),
1797        );
1798        let partial = asm.partial_turn(Some("msg_1".to_string()));
1799        let resolution = run
1800            .resolve_streamed_invalid_tool_call(
1801                &partial,
1802                &invalid,
1803                InvalidToolCallAction::retry("use add instead"),
1804            )
1805            .expect("retry resolution should be accepted");
1806        assert!(matches!(
1807            resolution,
1808            StreamedResolution::TurnAbandoned {
1809                skipped_tool_result: None
1810            }
1811        ));
1812        run.record_streamed_completion_call(
1813            Usage::new(),
1814            rig_core::completion::ResponseIdentity::default(),
1815            None,
1816            serde_json::Value::Null,
1817        )
1818        .expect("completion call should be recorded");
1819        assert_eq!(run.completion_calls().len(), 1);
1820
1821        let err = run
1822            .next_step()
1823            .expect_err("retry must not emit a second model call");
1824        assert!(matches!(
1825            err,
1826            PromptError::MaxTurnsError { max_turns: 1, .. }
1827        ));
1828        assert_eq!(run.turn(), 1);
1829    }
1830
1831    #[test]
1832    fn streamed_invalid_tool_call_skip_returns_synthetic_result() {
1833        let mut run = AgentRun::new("use the tool").max_turns(2);
1834        run.next_step().expect("next_step");
1835
1836        let mut asm = assembler();
1837        let invalid = expect_invalid(
1838            asm.ingest(&tool_call_item("tc_1", "default_api"))
1839                .expect("ingest should succeed"),
1840        );
1841        let partial = asm.partial_turn(None);
1842
1843        let resolution = run
1844            .resolve_streamed_invalid_tool_call(
1845                &partial,
1846                &invalid,
1847                InvalidToolCallAction::skip("not available"),
1848            )
1849            .expect("skip should be accepted");
1850        let StreamedResolution::TurnAbandoned {
1851            skipped_tool_result: Some(tool_result),
1852        } = &resolution
1853        else {
1854            panic!("expected skipped tool result");
1855        };
1856        assert_eq!(tool_result.call, "tc_1");
1857    }
1858
1859    #[test]
1860    fn streamed_invalid_name_delta_repair_replays_buffered_arguments() {
1861        let mut run = AgentRun::new("use the tool").max_turns(2);
1862        run.next_step().expect("next_step");
1863
1864        let mut asm = assembler();
1865        asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
1866            .expect("ingest should succeed");
1867        let invalid = expect_invalid(
1868            asm.ingest(&name_delta("tc_1", "default_api"))
1869                .expect("ingest should succeed"),
1870        );
1871        assert_eq!(invalid.args.as_deref(), Some("{\"x\":1}"));
1872
1873        let partial = asm.partial_turn(None);
1874        let resolution = run
1875            .resolve_streamed_invalid_tool_call(
1876                &partial,
1877                &invalid,
1878                InvalidToolCallAction::repair("add"),
1879            )
1880            .expect("repair should be accepted");
1881        assert!(matches!(
1882            resolution,
1883            StreamedResolution::Repaired { ref tool_name } if tool_name == "add"
1884        ));
1885
1886        let events = asm.resolve_pending_invalid(&resolution);
1887        let contents: Vec<_> = events
1888            .iter()
1889            .map(|event| match event {
1890                StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
1891                other => panic!("expected EmitToolCallDelta, got {other:?}"),
1892            })
1893            .collect();
1894        assert_eq!(
1895            contents,
1896            vec![
1897                ToolCallDeltaContent::Name("add".to_string()),
1898                ToolCallDeltaContent::Delta("{\"x\":1}".to_string()),
1899            ]
1900        );
1901    }
1902
1903    #[test]
1904    fn streamed_turn_rejects_unknown_tool_calls_fail_fast() {
1905        let mut run = AgentRun::new("use the tool");
1906        run.next_step().expect("next_step");
1907
1908        let turn = StreamedTurn {
1909            message_id: None,
1910            choice: vec![AssistantContent::ToolCall(tool_call("tc_1", "unknown"))],
1911            executable_tool_names: tool_names(&["add"]),
1912            allowed_tool_names: tool_names(&["add"]),
1913            internal_call_ids: Vec::new(),
1914            finish_reason: None,
1915        };
1916        let err = run
1917            .streamed_turn(turn)
1918            .expect_err("unknown tool should fail fast");
1919        assert!(matches!(
1920            err,
1921            PromptError::UnknownToolCall { tool_name, .. } if tool_name == "unknown"
1922        ));
1923    }
1924
1925    #[test]
1926    fn streamed_completion_call_record_requires_a_model_call() {
1927        // A fresh run has emitted no CallModel: recording must be rejected
1928        // even though the machine is in its initial PreparingRequest state.
1929        let mut run = AgentRun::new("hello");
1930        let err = run
1931            .record_streamed_completion_call(
1932                Usage::new(),
1933                rig_core::completion::ResponseIdentity::default(),
1934                None,
1935                serde_json::Value::Null,
1936            )
1937            .expect_err("recording before any model call must be rejected");
1938        assert!(matches!(err, PromptError::PromptCancelled { .. }));
1939
1940        // The run stays drivable.
1941        run.next_step().expect("next_step should still succeed");
1942        run.record_streamed_completion_call(
1943            Usage::new(),
1944            rig_core::completion::ResponseIdentity::default(),
1945            None,
1946            serde_json::Value::Null,
1947        )
1948        .expect("recording during a pending model call succeeds");
1949    }
1950
1951    #[test]
1952    fn duplicate_tool_call_ids_keep_distinct_internal_ids_through_the_run() {
1953        let mut run = AgentRun::new("do both").max_turns(2);
1954        run.next_step().expect("next_step");
1955
1956        let mut asm = assembler();
1957        asm.ingest(&StreamedAssistantContent::ToolCall {
1958            tool_call: tool_call("tc_1", "add"),
1959            internal_call_id: "internal_a".to_string(),
1960        })
1961        .expect("ingest should succeed");
1962        asm.ingest(&StreamedAssistantContent::ToolCall {
1963            tool_call: tool_call("tc_1", "add"),
1964            internal_call_id: "internal_b".to_string(),
1965        })
1966        .expect("ingest should succeed");
1967        run.record_streamed_completion_call(
1968            Usage::new(),
1969            rig_core::completion::ResponseIdentity::default(),
1970            None,
1971            serde_json::Value::Null,
1972        )
1973        .expect("record should succeed");
1974
1975        let final_choice = vec![
1976            AssistantContent::ToolCall(tool_call("tc_1", "add")),
1977            AssistantContent::ToolCall(tool_call("tc_1", "add")),
1978        ];
1979        run.streamed_turn(asm.finish(None, &final_choice))
1980            .expect("streamed_turn should succeed");
1981
1982        // The internal IDs survive in the run state itself: a serde round
1983        // trip must keep both calls distinguishable.
1984        let serialized = serde_json::to_string(&run).expect("serialize");
1985        let mut restored: AgentRun = serde_json::from_str(&serialized).expect("deserialize");
1986        let AgentRunStep::CallTools { calls } = restored.next_step().expect("next_step") else {
1987            panic!("expected CallTools");
1988        };
1989        assert_eq!(calls.len(), 2);
1990        assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_a"));
1991        assert_eq!(calls[1].internal_call_id.as_deref(), Some("internal_b"));
1992    }
1993
1994    #[test]
1995    fn streamed_turn_records_the_completion_call_when_the_driver_did_not() {
1996        let mut run = AgentRun::new("hello");
1997        run.next_step().expect("next_step");
1998
1999        let asm = assembler();
2000        let final_choice = vec![AssistantContent::text("done")];
2001        run.streamed_turn(asm.finish(None, &final_choice))
2002            .expect("streamed_turn should succeed");
2003
2004        // Exactly one CompletionCall per model call, even without an explicit
2005        // record; usage is simply unreported.
2006        assert_eq!(run.completion_calls().len(), 1);
2007        assert_eq!(run.completion_calls()[0].usage, Usage::new());
2008    }
2009
2010    #[test]
2011    fn streamed_completion_call_is_recorded_once_per_turn() {
2012        let mut run = AgentRun::new("hello");
2013        run.next_step().expect("next_step");
2014
2015        run.record_streamed_completion_call(
2016            Usage::new(),
2017            rig_core::completion::ResponseIdentity::default(),
2018            None,
2019            serde_json::Value::Null,
2020        )
2021        .expect("first record succeeds");
2022        let err = run
2023            .record_streamed_completion_call(
2024                Usage::new(),
2025                rig_core::completion::ResponseIdentity::default(),
2026                None,
2027                serde_json::Value::Null,
2028            )
2029            .expect_err("second record for the same turn must be rejected");
2030        assert!(matches!(err, PromptError::PromptCancelled { .. }));
2031        assert_eq!(run.completion_calls().len(), 1);
2032    }
2033
2034    #[test]
2035    fn streamed_run_serde_round_trips_while_tools_pend() {
2036        let mut run = AgentRun::new("add things").max_turns(2);
2037        run.next_step().expect("next_step");
2038
2039        let mut asm = assembler();
2040        asm.ingest(&tool_call_item("tc_1", "add"))
2041            .expect("ingest should succeed");
2042        run.record_streamed_completion_call(
2043            Usage::new(),
2044            rig_core::completion::ResponseIdentity::default(),
2045            None,
2046            serde_json::Value::Null,
2047        )
2048        .expect("record should succeed");
2049        let final_choice = vec![AssistantContent::ToolCall(tool_call("tc_1", "add"))];
2050        run.streamed_turn(asm.finish(None, &final_choice))
2051            .expect("streamed_turn should succeed");
2052        run.next_step().expect("CallTools step");
2053
2054        let serialized = serde_json::to_string(&run).expect("serialize mid-run");
2055        let mut restored: AgentRun =
2056            serde_json::from_str(&serialized).expect("deserialize mid-run");
2057        restored
2058            .tool_results(vec![UserContent::tool_result(
2059                "tc_1",
2060                "add",
2061                vec![ToolResultContent::text("2")],
2062            )])
2063            .expect("tool_results should succeed");
2064        assert!(matches!(
2065            restored.next_step().expect("next turn"),
2066            AgentRunStep::CallModel { turn: 2, .. }
2067        ));
2068    }
2069}