Skip to main content

rig_agent/agent/prompt_request/
streaming.rs

1use rig_core::{
2    message::{AssistantContent, UserContent},
3    telemetry::SpanCombinator,
4    wasm_compat::{WasmBoxedFuture, WasmCompatSend},
5};
6
7use crate::{
8    agent::completion::{PreparedCompletionRequest, build_prepared_completion_request},
9    agent::hook::{
10        AgentHook, HookContext, HookStack, InvalidToolCallAction, ModelSelection,
11        ModelSelectionAction, ModelTurnFinished, ReasoningDelta, StepEventKind,
12        StreamResponseFinish, TextDelta, ToolCallDelta,
13    },
14    agent::prompt_request::{assistant_text_from_choice, is_empty_assistant_turn},
15    agent::run::{
16        AgentRun, AgentRunStep, PendingToolCall,
17        streamed::{StreamedResolution, StreamedTurnAssembler, StreamedTurnEvent},
18    },
19    agent::runner::{
20        AgentRunner, CompletionCallOutcome, ModelTurnDecision, ToolExecution, append_run_messages,
21        build_chat_span, new_execute_tool_span, observe_action, resolve_completion_call,
22        resolve_model_turn_action, run_single_tool,
23    },
24    streaming::{StreamedAssistantContent, StreamedUserContent, ToolCallDeltaContent},
25    tool::{ToolContext, server::ToolRegistrySnapshot},
26};
27use futures::{Stream, StreamExt, stream};
28use serde::{Deserialize, Serialize};
29use std::{collections::VecDeque, pin::Pin, sync::Arc};
30use tracing_futures::Instrument;
31
32use super::{CompletionCall, PromptResponse, forward_prompt_setters};
33use crate::{
34    agent::{Agent, model::ModelHandle},
35    completion::{CompletionError, PromptError},
36};
37use rig_core::message::{Message, Text};
38
39// The `Send` bound is dropped exactly where `rig-core`'s `WasmCompat*` markers
40// go no-op — browser wasm. `rig-core` keys those markers on this same
41// predicate, so keep the two in step: a bare `target_arch = "wasm32"` would
42// also drop `Send` on WASI, where `rig-core` still requires it.
43#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
44pub type StreamingResult =
45    Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>> + Send>>;
46
47#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
48pub type StreamingResult = Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>>>>;
49
50#[derive(Deserialize, Serialize, Debug, Clone)]
51#[serde(tag = "type", rename_all = "camelCase")]
52pub enum MultiTurnStreamItem {
53    /// A streamed assistant content item — the content the **model emitted**:
54    /// text/reasoning deltas, tool-call deltas, and, when the model turn is
55    /// committed, the complete [`StreamedAssistantContent::ToolCall`] for each
56    /// tool call Rig routes to execution. Such a call is reported here whether or
57    /// not the tool body ultimately runs (a hook skip still reports it);
58    /// it is **not** an execution-lifecycle event (see
59    /// [`ToolExecutionCommitted`](Self::ToolExecutionCommitted)).
60    ///
61    /// Two kinds of model tool call are **not** re-emitted as a complete
62    /// `ToolCall` item here (their arguments still stream as tool-call deltas):
63    /// a call rejected and handled by invalid-tool-call recovery (surfaced via
64    /// that recovery path), and a structured-output Tool-mode output-tool call,
65    /// which finalizes the run directly — its structured result is surfaced in
66    /// the [`FinalResponse`](Self::FinalResponse) rather than as a completed
67    /// `ToolCall` item.
68    StreamAssistantItem(StreamedAssistantContent),
69    /// Confirmation that Rig **executed and committed** a tool call. This is not
70    /// a real-time start notification: it is surfaced together with its
71    /// `ToolResult` only after the whole batch settles successfully. Use tool
72    /// hooks for live host-side start/result observation.
73    ///
74    /// This item is emitted only for a tool whose body actually ran (it passed
75    /// its `ToolCall` hook checks), never for a call dropped by a sibling's
76    /// termination, skipped by a hook, or resolved by invalid-call recovery.
77    /// Correlate it with the model call and result through `internal_call_id`.
78    ToolExecutionCommitted {
79        /// The tool call as **executed**: the model's call with any
80        /// [`ToolCallAction::Rewrite`](crate::agent::ToolCallAction::Rewrite) hook rewrite
81        /// applied (so a redaction rewrite is reflected here, not leaked). The
82        /// model's *original* call is reported via
83        /// [`StreamAssistantItem`](Self::StreamAssistantItem).
84        tool_call: rig_core::message::ToolCall,
85        /// Rig-generated id correlating this execution with the model tool call
86        /// ([`StreamedAssistantContent::ToolCall::internal_call_id`]) and the
87        /// resulting [`StreamedUserContent::ToolResult`].
88        internal_call_id: String,
89    },
90    /// A streamed user content item: the **result** of an executed (or
91    /// hook-skipped) tool call. The tool batch commits and surfaces atomically at
92    /// every `tool_concurrency` (including the sequential default): results are
93    /// surfaced (in call order) only after the whole batch settles successfully —
94    /// a run that terminates mid-batch surfaces no successful tool results.
95    StreamUserItem(StreamedUserContent),
96    /// Details for one successfully completed completion request made by this agent stream.
97    ///
98    /// This is emitted when a provider call finishes. Usage is the provider's
99    /// final usage for that completion request when available; it is not
100    /// incremental per streamed token.
101    ///
102    /// ```rust,ignore
103    /// match item {
104    ///     MultiTurnStreamItem::CompletionCall(completion_call) => {
105    ///         // Zero-valued usage means the provider reported no metrics.
106    ///         if completion_call.usage.has_values() {
107    ///             let context_tokens = completion_call.usage.input_tokens;
108    ///         }
109    ///     }
110    ///     _ => {}
111    /// }
112    /// ```
113    CompletionCall(CompletionCall),
114    /// The completed model turn was rejected by a hook for retry.
115    ///
116    /// Text and reasoning deltas emitted for this turn were provisional. A
117    /// consumer should discard or visually reset output associated with `turn`.
118    /// A subsequent attempt is made only if the run's total model-call budget
119    /// permits it.
120    ModelTurnRetried {
121        /// One-based model-call index of the rejected turn.
122        turn: usize,
123    },
124    /// The final result from the stream: the unified [`PromptResponse`] shared
125    /// with the blocking surface.
126    FinalResponse(PromptResponse),
127}
128
129/// Build the unified [`PromptResponse`] for the streaming surface from the
130/// final turn's structured content.
131fn final_response_from_content(
132    content: Vec<AssistantContent>,
133    aggregated_usage: crate::completion::Usage,
134    completion_calls: Vec<CompletionCall>,
135    history: Option<Vec<Message>>,
136) -> PromptResponse {
137    let mut response = PromptResponse::new(assistant_text_from_choice(&content), aggregated_usage)
138        .with_content(content)
139        .with_completion_calls(completion_calls);
140    response.messages = history;
141    response
142}
143
144impl MultiTurnStreamItem {
145    pub(crate) fn stream_item(item: StreamedAssistantContent) -> Self {
146        Self::StreamAssistantItem(item)
147    }
148
149    /// Build a `FinalResponse` item from final-turn content, applying the
150    /// run-finalization shaping of `final_response_from_content` (#1928).
151    /// The one public entry point to that shaping, for mocks and adapters
152    /// that synthesize final items outside the drive loop.
153    pub fn final_response(
154        content: Vec<AssistantContent>,
155        aggregated_usage: crate::completion::Usage,
156    ) -> Self {
157        Self::FinalResponse(final_response_from_content(
158            content,
159            aggregated_usage,
160            Vec::new(),
161            None,
162        ))
163    }
164
165    pub(crate) fn final_response_with_completion_calls(
166        content: Vec<AssistantContent>,
167        aggregated_usage: crate::completion::Usage,
168        completion_calls: Vec<CompletionCall>,
169        history: Option<Vec<Message>>,
170    ) -> Self {
171        Self::FinalResponse(final_response_from_content(
172            content,
173            aggregated_usage,
174            completion_calls,
175            history,
176        ))
177    }
178}
179
180/// Drain a provider stream abandoned by invalid tool-call recovery so the
181/// reported usage for the recovered completion call is not lost.
182async fn drain_stream_usage(
183    stream: &mut crate::streaming::StreamingCompletionResponse,
184) -> Result<crate::completion::Usage, StreamingError> {
185    while let Some(content) = stream.next().await {
186        match content {
187            Ok(StreamedAssistantContent::Final(final_resp)) => {
188                return Ok(final_resp.usage);
189            }
190            Ok(_) => {}
191            Err(err) => return Err(err.into()),
192        }
193    }
194
195    Ok(crate::completion::Usage::new())
196}
197
198/// Build the final streamed content for a finished run (#1928).
199///
200/// When the finishing turn carries a tool call it is a Tool-mode output-tool
201/// call (a real tool call would have routed to `CallTools`, not `Done`). In that
202/// case the tool call AND the model's prose are dropped, any reasoning/image
203/// content is kept, and `output` is appended as the final text — so the streamed
204/// [`PromptResponse::output`] string is the structured output rather than the
205/// prose, with no unanswered tool_use, matching the non-streaming `output`. Note
206/// this shapes only the surfaced [`PromptResponse::content`]; the persisted
207/// message history is built by the state machine (which keeps the prose, like the
208/// blocking driver), so `content` and `messages` intentionally differ on prose in
209/// this case.
210/// Otherwise returns `None` and the caller surfaces the turn's content unchanged.
211fn finalize_streamed_choice(
212    last_final_choice: &[AssistantContent],
213    output: &str,
214) -> Option<Vec<AssistantContent>> {
215    let finalized_via_output_tool = last_final_choice
216        .iter()
217        .any(|item| matches!(item, AssistantContent::ToolCall(_)));
218    if !finalized_via_output_tool {
219        return None;
220    }
221    let mut items: Vec<AssistantContent> = last_final_choice
222        .iter()
223        .filter(|item| {
224            !matches!(
225                item,
226                AssistantContent::ToolCall(_) | AssistantContent::Text(_)
227            )
228        })
229        .cloned()
230        .collect();
231    // `items` is non-empty: the output text was just pushed unconditionally.
232    items.push(AssistantContent::text(output.to_string()));
233    Some(items)
234}
235
236#[derive(Debug, thiserror::Error)]
237pub enum StreamingError {
238    #[error("CompletionError: {0}")]
239    Completion(#[from] CompletionError),
240    #[error("PromptError: {0}")]
241    Prompt(#[from] Box<PromptError>),
242}
243
244impl From<rig_core::memory::MemoryError> for StreamingError {
245    fn from(err: rig_core::memory::MemoryError) -> Self {
246        Self::Prompt(Box::new(PromptError::MemoryError(err)))
247    }
248}
249
250/// A builder for creating prompt requests with customizable options.
251/// Uses generics to track which options have been set during the build process.
252///
253/// When the agent has no configured `default_max_turns`, the implicit budget is
254/// one model call. Use [`.max_turns()`](Self::max_turns) to override the agent's
255/// configured or implicit budget; a tool call followed by a model-authored final
256/// answer generally requires at least two model calls.
257pub struct StreamingPromptRequest {
258    /// The hook-aware driver this streaming request configures and runs.
259    runner: AgentRunner,
260}
261
262impl StreamingPromptRequest {
263    /// Create a new `StreamingPromptRequest` from an agent, including its
264    /// default hooks.
265    pub fn new(agent: Arc<Agent>, prompt: impl Into<Message>) -> StreamingPromptRequest {
266        Self::from_agent(agent.as_ref(), prompt)
267    }
268
269    /// Create a new StreamingPromptRequest from an agent, cloning the agent's
270    /// data and default hook stack.
271    pub fn from_agent(agent: &Agent, prompt: impl Into<Message>) -> StreamingPromptRequest {
272        StreamingPromptRequest {
273            runner: AgentRunner::from_agent(agent, prompt),
274        }
275    }
276
277    /// Set the total model-call budget, including the initial call and every
278    /// retry or continuation. Zero emits no model calls; one permits only the
279    /// initial call.
280    ///
281    /// Named to match the blocking
282    /// [`PromptRequest::max_turns`](super::PromptRequest::max_turns) and
283    /// [`TypedPromptRequest::max_turns`](super::TypedPromptRequest::max_turns)
284    /// builders so the same call reads identically on either surface.
285    pub fn max_turns(mut self, turns: usize) -> Self {
286        self.runner = self.runner.max_turns(turns);
287        self
288    }
289
290    /// Execute up to `concurrency` of a turn's tool calls at once (1 by default,
291    /// i.e. sequential). See [`AgentRunner::tool_concurrency`]: at any
292    /// `concurrency` the stream emits the model's `ToolCall` items (call order),
293    /// then — atomically, after the whole tool batch settles successfully — the
294    /// per-tool `ToolExecutionCommitted` + `ToolResult` items in **call order** (not
295    /// completion order). The streamed message history is unchanged at any
296    /// `concurrency`.
297    pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
298        self.runner = self.runner.tool_concurrency(concurrency);
299        self
300    }
301
302    /// Append a hook to this request's hook stack (on top of any the agent
303    /// already carries). Hooks run in registration order; how their results
304    /// compose is event-dependent (model selections and `ToolCall`/`ToolResult` rewrites
305    /// chain, `CompletionCall` request patches accumulate and merge, while model-turn
306    /// steering and observe-only/recovery events use first-non-`Continue`-wins). See the
307    /// [`hook`](crate::agent::hook) module docs.
308    pub fn add_hook<H>(mut self, hook: H) -> Self
309    where
310        H: AgentHook + 'static,
311    {
312        self.runner = self.runner.add_hook(hook);
313        self
314    }
315
316    forward_prompt_setters!(runner);
317
318    async fn send(self) -> StreamingResult {
319        self.runner.stream().await
320    }
321}
322
323/// A boxed, medium-specific item stream for one engine step (model turn or tool
324/// batch). Boxed so a generic [`drive_agent`] can forward it without the
325/// per-step future leaking into the engine's own (`Send`) inference.
326// Same browser-wasm predicate as `StreamingResult` above, for the same reason.
327#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
328pub(crate) type DriveStream<'a> =
329    Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>> + Send + 'a>>;
330
331#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
332pub(crate) type DriveStream<'a> =
333    Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>> + 'a>>;
334
335/// One item emitted by the shared engine [`drive_agent`].
336///
337/// `Item`s are forwarded to a streaming consumer (and ignored by the blocking
338/// fold); `Done` carries both the canonical [`PromptResponse`] the blocking
339/// surface returns and the medium-specific final stream item the streaming
340/// surface yields.
341// The large `Item` variant is the per-delta hot path (one per streamed token);
342// boxing it to shrink the variant spread would add an allocation per delta,
343// which the streaming path is specifically tuned to avoid. `Done` is yielded
344// once per run, so the wasted space on that rare variant is irrelevant.
345#[allow(clippy::large_enum_variant)]
346pub(crate) enum DriveItem {
347    /// An intermediate stream item (assistant delta, tool call/result, a
348    /// per-call `CompletionCall`, or — last, for the streaming surface — the
349    /// final response item).
350    Item(MultiTurnStreamItem),
351    /// The run finished; carries the canonical response the blocking fold
352    /// returns. The streaming surface has already received the final item as the
353    /// preceding `Item` and ignores this.
354    Done(Box<PromptResponse>),
355}
356
357/// The per-medium half of the agent loop: how a turn is fetched from the model,
358/// how its tools are executed, and how the run's spans/usage/final item are
359/// shaped. The medium-independent outer loop (turn counting, the `CompletionCall`
360/// hook, request preparation, memory) lives once in [`drive_agent`]; only the
361/// genuinely divergent pieces are behind this trait. Invalid-tool-call recovery
362/// is one of them — it lives inside each source's `run_model_turn` (end-of-turn
363/// for blocking, mid-stream for streaming), not in `drive_agent`.
364pub(crate) trait TurnSource: WasmCompatSend {
365    /// Build this medium's per-turn `chat` span (name + parenting + any
366    /// `follows_from` chaining differ between blocking and streaming).
367    fn open_chat_span(
368        &self,
369        runner: &AgentRunner,
370        effective_preamble: Option<&str>,
371    ) -> tracing::Span;
372
373    /// Run one model turn: issue the provider call, feed the result into the
374    /// sans-IO machine, and yield any intermediate items. Returning normally
375    /// advances the loop; yielding an `Err` terminates the run.
376    #[allow(clippy::too_many_arguments)]
377    fn run_model_turn<'a>(
378        &'a mut self,
379        runner: &'a AgentRunner,
380        hook_ctx: &'a HookContext,
381        run: &'a mut AgentRun,
382        prepared: PreparedCompletionRequest,
383        chat_span: tracing::Span,
384        agent_span: &'a tracing::Span,
385        prompt: Message,
386    ) -> DriveStream<'a>;
387
388    /// Execute a turn's tool calls, feeding the results into the machine and
389    /// yielding any intermediate items.
390    fn run_tool_calls<'a>(
391        &'a self,
392        runner: &'a AgentRunner,
393        hook_ctx: &'a HookContext,
394        run: &'a mut AgentRun,
395        calls: Vec<PendingToolCall>,
396        tool_snapshot: Arc<ToolRegistrySnapshot>,
397    ) -> DriveStream<'a>;
398
399    /// Record run-level telemetry onto the agent span at `Done`. Gated on
400    /// `created_agent_span` so a caller-supplied outer span is never polluted.
401    fn record_run_level_telemetry(
402        &self,
403        agent_span: &tracing::Span,
404        response: &PromptResponse,
405        created_agent_span: bool,
406    );
407
408    /// Build the final stream item surfaced at `Done`, or `None` when the
409    /// surface discards it (the blocking fold) so the engine skips the work.
410    fn final_item(&self, response: &PromptResponse) -> Option<MultiTurnStreamItem>;
411}
412
413/// Convert a [`StreamingError`] back into a [`PromptError`] for the blocking
414/// surface ([`AgentRunner::run`]), which folds the shared engine. Lossless:
415/// every streaming error originates as one of these.
416pub(crate) fn streaming_error_into_prompt(err: StreamingError) -> PromptError {
417    match err {
418        StreamingError::Completion(err) => PromptError::CompletionError(err),
419        StreamingError::Prompt(err) => *err,
420    }
421}
422
423pub(crate) fn store_error_usage(runner: &AgentRunner, run: &AgentRun) {
424    if let Some(usage) = &runner.error_usage {
425        *usage.lock().unwrap_or_else(|error| error.into_inner()) = run.usage();
426    }
427}
428
429/// The single agent drive loop, shared by the blocking and streaming surfaces.
430///
431/// Owns the medium-independent loop — `next_step` dispatch, the `CompletionCall`
432/// hook + request preparation, the `Done` memory append — and delegates the
433/// medium-specific model call, tool execution, span shaping and finalization to
434/// a [`TurnSource`]. The streaming surface forwards the yielded [`DriveItem`]s;
435/// the blocking surface folds them to `Done`.
436pub(crate) fn drive_agent<S>(
437    runner: AgentRunner,
438    mut source: S,
439    mut run: AgentRun,
440    agent_span: tracing::Span,
441    created_agent_span: bool,
442    memory_handle: Option<(Arc<dyn rig_core::memory::ConversationMemory>, String)>,
443    is_streaming: bool,
444) -> impl Stream<Item = Result<DriveItem, StreamingError>>
445where
446    S: TurnSource,
447{
448    async_stream::stream! {
449        // Run-scoped hook context: minted once, shared by every hook event on
450        // both surfaces. `is_streaming` records which surface is driving; the
451        // per-turn index is advanced on each `CallModel` step below.
452        let hook_ctx = HookContext::new(is_streaming, runner.config.name.clone());
453        // Set only after a model turn commits successfully and consumed by its
454        // immediately following CallTools step. This keeps the sans-IO run state
455        // serializable while pinning execution to the definitions sent that turn.
456        let mut pending_tool_snapshot: Option<Arc<ToolRegistrySnapshot>> = None;
457        // Live routing state stays in the driver, not the serde `AgentRun`. It
458        // records the model behind the preceding *issued* attempt: it advances
459        // immediately before the selected model's unary or streaming operation
460        // is invoked, so a completion-call stop, selection stop, or preparation
461        // failure leaves it unchanged while a provider error still counts.
462        let mut previous_model: Option<ModelHandle> = None;
463
464        // Drive one medium-specific step stream: forward its items, and on the
465        // first error store error usage, surface it, and end the run. A macro
466        // because `yield`/`break 'outer` cannot cross a fn boundary; the loop
467        // label is passed in because labels are hygienic across the macro edge.
468        macro_rules! drive_step {
469            ($label:lifetime, $step_stream:expr) => {{
470                let mut step_stream = $step_stream;
471                let mut step_error = None;
472                while let Some(item) = step_stream.next().await {
473                    match item {
474                        Ok(item) => yield Ok(DriveItem::Item(item)),
475                        Err(err) => {
476                            step_error = Some(err);
477                            break;
478                        }
479                    }
480                }
481                drop(step_stream);
482                if let Some(err) = step_error {
483                    store_error_usage(&runner, &run);
484                    yield Err(err);
485                    break $label;
486                }
487            }};
488        }
489
490        'outer: loop {
491            let step = match run.next_step() {
492                Ok(step) => step,
493                Err(err) => {
494                    store_error_usage(&runner, &run);
495                    yield Err(Box::new(err).into());
496                    break 'outer;
497                }
498            };
499
500            match step {
501                AgentRunStep::CallModel { prompt, history, turn } => {
502                    drop(pending_tool_snapshot.take());
503                    if runner.config.max_turns > 1 {
504                        tracing::info!("Current conversation Turns: {}/{}", turn, runner.config.max_turns);
505                    }
506                    hook_ctx.set_turn(turn);
507
508                    // Completion-call hooks resolve FIRST: a stop here suppresses
509                    // model selection entirely, and their merged `RequestPatch`
510                    // is handed to the selection hooks below.
511                    let request_patch =
512                        match resolve_completion_call(&runner.config.hooks, &hook_ctx, &prompt, &history, turn).await {
513                            CompletionCallOutcome::Terminate(reason) => {
514                                store_error_usage(&runner, &run);
515                                yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
516                                break 'outer;
517                            }
518                            CompletionCallOutcome::Proceed(request_patch) => request_patch,
519                        };
520
521                    // Resolve routing once at the model-call boundary, after the
522                    // completion-call hooks proceed. The resulting handle is
523                    // cloned into the prepared attempt, so request preparation
524                    // inspects the *selected* model's captured capabilities and
525                    // the same handle executes the request.
526                    let selected_model = match runner.config.hooks.on_model_select(
527                        &hook_ctx,
528                        ModelSelection {
529                            prompt: &prompt,
530                            history: &history,
531                            request_patch: request_patch.as_ref(),
532                            previous_model: previous_model.as_ref(),
533                            default_model: &runner.config.model,
534                            selected_model: &runner.config.model,
535                        },
536                    ) {
537                        ModelSelectionAction::Continue => runner.config.model.clone(),
538                        ModelSelectionAction::Select(model) => model,
539                        ModelSelectionAction::Stop(reason) => {
540                            store_error_usage(&runner, &run);
541                            yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
542                            break 'outer;
543                        }
544                    };
545
546                    // Record this turn's base system prompt — the patched-or-baseline
547                    // preamble, before any output-mode augmentation the request builder
548                    // appends. Borrow rather than clone since it only needs to outlive
549                    // span creation.
550                    let effective_preamble = request_patch
551                        .as_ref()
552                        .and_then(|o| o.preamble.as_deref())
553                        .or(runner.config.preamble.as_deref());
554
555                    let chat_span = source.open_chat_span(&runner, effective_preamble);
556
557                    // Pin Tool output mode once committed so later turns stay
558                    // consistent even if the per-turn tool set changes (#1928).
559                    let committed_output_tool = run.output_tool_name().map(str::to_owned);
560                    let mut prepared = match build_prepared_completion_request(
561                        &runner,
562                        &selected_model,
563                        prompt.clone(),
564                        &history,
565                        committed_output_tool.as_deref(),
566                        request_patch.as_ref(),
567                    )
568                    .await
569                    {
570                        Ok(prepared) => prepared,
571                        Err(err) => {
572                            store_error_usage(&runner, &run);
573                            yield Err(err.into());
574                            break 'outer;
575                        }
576                    };
577                    run.set_output_tool_name(prepared.output_tool_name.clone());
578                    let turn_tool_snapshot = prepared.tool_snapshot.clone();
579                    if runner.config.record_telemetry_content {
580                        let input_messages = prepared.builder.messages_for_telemetry();
581                        rig_core::telemetry::record_model_input(&chat_span, &input_messages, true);
582                        prepared.builder = prepared.builder.record_content_telemetry(false);
583                    }
584
585                    // The attempt is now committed: advance `previous_model`
586                    // immediately before the model turn is driven (the
587                    // streaming request is issued on first poll of the turn
588                    // stream). An issued attempt counts even when
589                    // the provider returns an error; every stop/error path
590                    // above left `previous_model` untouched.
591                    previous_model = Some(selected_model);
592
593                    drive_step!('outer, source.run_model_turn(
594                        &runner,
595                        &hook_ctx,
596                        &mut run,
597                        prepared,
598                        chat_span,
599                        &agent_span,
600                        prompt,
601                    ));
602                    pending_tool_snapshot = Some(turn_tool_snapshot);
603                }
604                AgentRunStep::CallTools { calls } => {
605                    let Some(tool_snapshot) = pending_tool_snapshot.take() else {
606                        store_error_usage(&runner, &run);
607                        yield Err(StreamingError::Completion(CompletionError::ResponseError(
608                            "agent requested tool execution without a prepared registry snapshot"
609                                .to_string(),
610                        )));
611                        break 'outer;
612                    };
613                    drive_step!('outer, source.run_tool_calls(
614                        &runner,
615                        &hook_ctx,
616                        &mut run,
617                        calls,
618                        tool_snapshot,
619                    ));
620                }
621                AgentRunStep::Done(response) => {
622                    // Run-completion marker, unifying the blocking and streaming
623                    // drivers' run-finished logs into one shared event.
624                    tracing::info!(
625                        turn = run.turn(),
626                        max_turns = runner.config.max_turns,
627                        "Agent run finished"
628                    );
629                    source.record_run_level_telemetry(&agent_span, &response, created_agent_span);
630                    append_run_messages(
631                        memory_handle.as_ref(),
632                        response.messages.as_deref().unwrap_or_default(),
633                    )
634                    .await;
635                    // Build the final item only when the surface forwards it
636                    // (streaming). The blocking fold discards it, so its source
637                    // returns `None` and the extra full-response clone is skipped.
638                    if let Some(final_item) = source.final_item(&response) {
639                        yield Ok(DriveItem::Item(final_item));
640                    }
641                    yield Ok(DriveItem::Done(Box::new(response)));
642                    break 'outer;
643                }
644            }
645        }
646    }
647}
648
649/// Execute a turn's tool calls **atomically per batch**, shared by both surfaces.
650///
651/// The batch commits and surfaces all-or-nothing:
652///
653/// - The model tool-call events ([`StreamedAssistantContent::ToolCall`]) are
654///   emitted up front — they report what the model emitted at turn commit.
655/// - Every tool then runs (sequentially at `tool_concurrency <= 1`, else
656///   concurrently bounded by it), with outcomes **collected, not surfaced**.
657/// - On the first hook termination / fail-closed error the batch fails fast: no
658///   new tool starts, not-yet-started concurrent siblings are dropped,
659///   already-started ones are drained, and the deterministic lowest call-index
660///   error is surfaced with **no** successful [`ToolExecutionCommitted`] /
661///   [`StreamUserItem`](MultiTurnStreamItem::StreamUserItem) items and **no**
662///   history commit.
663/// - Only if the whole batch settles successfully are the per-tool
664///   [`ToolExecutionCommitted`](MultiTurnStreamItem::ToolExecutionCommitted) + result
665///   items surfaced (in call order, only for tools whose body actually ran) and
666///   the results committed to run history.
667///
668/// When `forward_items` is `false` (the blocking fold) no stream items are built,
669/// but the collect/commit and fail-fast behavior is identical, so `run()` and
670/// `stream()` return the same terminal reason. `chain_tool_span` lets the
671/// blocking surface chain spans into its linear `follows_from` sequence.
672pub(crate) fn drive_tool_calls<'a, F>(
673    runner: &'a AgentRunner,
674    hook_ctx: &'a HookContext,
675    run: &'a mut AgentRun,
676    calls: Vec<PendingToolCall>,
677    tool_snapshot: Arc<ToolRegistrySnapshot>,
678    chain_tool_span: F,
679    forward_items: bool,
680) -> DriveStream<'a>
681where
682    F: Fn(tracing::Span) -> tracing::Span + WasmCompatSend + 'a,
683{
684    // Per-call working state: a stable internal_call_id and the execute span,
685    // paired with the model's tool call. `span` is `Span::none()` for a
686    // preresolved (invalid-recovery) call, which never executes.
687    struct PreparedToolCall {
688        tool_call: rig_core::message::ToolCall,
689        preresolved_result: Option<UserContent>,
690        internal_call_id: String,
691        span: tracing::Span,
692    }
693    // How a settled tool call is surfaced on the stream once the batch succeeds:
694    //   - `Executed`: `ToolExecutionCommitted` (with the effective, hook-rewritten
695    //     call) + the `ToolResult`.
696    //   - `Skipped`: the `ToolResult` only (a `ToolCall` hook returned `Skip`, so
697    //     nothing ran — no execution commit — but the model still sees the result).
698    //   - `Preresolved`: neither (an invalid-recovery result, already surfaced
699    //     during the model turn); committed to history only.
700    enum ToolSurface {
701        // Boxed to keep this enum small next to the empty `Skipped`/`Preresolved`.
702        Executed(Box<rig_core::message::ToolCall>),
703        Skipped,
704        Preresolved,
705    }
706    // A collected tool outcome, held (not surfaced or committed) until the whole
707    // batch settles.
708    struct CollectedToolResult {
709        content: UserContent,
710        internal_call_id: String,
711        surface: ToolSurface,
712    }
713
714    Box::pin(async_stream::stream! {
715        let full_history_for_errors = run.full_history();
716        let call_count = calls.len();
717
718        // Assign each call a stable internal_call_id and, for calls that will
719        // actually execute, an execute span. Emit the MODEL tool-call events now,
720        // right after the turn committed: these report what the model emitted and
721        // are *not* execution-lifecycle events. A preresolved call emits no model
722        // tool-call event (its synthetic result was already surfaced during the
723        // model turn) and gets no execute span.
724        let mut prepared: Vec<PreparedToolCall> = Vec::with_capacity(call_count);
725        for pending in calls {
726            let internal_call_id = pending.internal_call_id.unwrap_or_else(rig_core::id::generate);
727            let (span, preresolved_result) = match pending.preresolved_result {
728                Some(result) => (tracing::Span::none(), Some(result)),
729                None => {
730                    if forward_items {
731                        yield Ok(MultiTurnStreamItem::stream_item(
732                            StreamedAssistantContent::ToolCall {
733                                tool_call: pending.tool_call.clone(),
734                                internal_call_id: internal_call_id.clone(),
735                            },
736                        ));
737                    }
738                    (chain_tool_span(new_execute_tool_span()), None)
739                }
740            };
741            prepared.push(PreparedToolCall {
742                tool_call: pending.tool_call,
743                preresolved_result,
744                internal_call_id,
745                span,
746            });
747        }
748
749        // Run all tools, COLLECTING outcomes in call order — nothing is surfaced
750        // or committed until the whole batch settles (atomic per-batch). On the
751        // first hook termination / fail-closed error we stop starting new tools;
752        // already-started ones are drained; the lowest call-index error wins; and
753        // no successful result is surfaced or committed.
754        let mut collected: Vec<Option<CollectedToolResult>> =
755            (0..call_count).map(|_| None).collect();
756        let mut first_error: Option<(usize, PromptError)> = None;
757
758        {
759            // Bounded by `tool_concurrency` (`0`/`1` poll strictly in call
760            // order, giving sequential fail-fast). A shared `terminating`
761            // flag makes a not-yet-started sibling skip (its side effect never
762            // runs) once any sibling terminates — avoiding the Semantic-Kernel
763            // fail-open — while already-in-flight siblings are drained so the
764            // lowest call-index terminator wins and no task is left detached.
765            let terminating = Arc::new(std::sync::atomic::AtomicBool::new(false));
766            let unordered = stream::iter(prepared.into_iter().enumerate())
767                .map(|(index, call)| {
768                    let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
769                    let tool_snapshot = &tool_snapshot;
770                    let full_history_for_errors = &full_history_for_errors;
771                    let terminating = terminating.clone();
772                    async move {
773                        if let Some(result) = preresolved_result {
774                            return (
775                                index,
776                                Some(Ok(CollectedToolResult {
777                                    content: result,
778                                    internal_call_id,
779                                    surface: ToolSurface::Preresolved,
780                                })),
781                            );
782                        }
783                        // `None` marks a dropped (never-started) sibling.
784                        if terminating.load(std::sync::atomic::Ordering::SeqCst) {
785                            return (index, None);
786                        }
787                        let outcome = run_single_tool(
788                            runner,
789                            hook_ctx,
790                            tool_snapshot,
791                            &tool_call,
792                            &internal_call_id,
793                            full_history_for_errors,
794                        )
795                        .await;
796                        let mapped = outcome.map(|o| {
797                            let surface = match o.execution {
798                                ToolExecution::Executed(effective) => {
799                                    ToolSurface::Executed(effective)
800                                }
801                                ToolExecution::Skipped => ToolSurface::Skipped,
802                            };
803                            CollectedToolResult {
804                                content: o.content,
805                                internal_call_id,
806                                surface,
807                            }
808                        });
809                        (index, Some(mapped))
810                    }
811                    .instrument(span)
812                })
813                .buffer_unordered(runner.concurrency.max(1));
814            futures::pin_mut!(unordered);
815
816            while let Some((index, outcome)) = unordered.next().await {
817                // A dropped sibling records nothing.
818                let result = match outcome {
819                    Some(result) => result,
820                    None => continue,
821                };
822                match result {
823                    Ok(collected_result) => {
824                        if let Some(slot) = collected.get_mut(index) {
825                            *slot = Some(collected_result);
826                        }
827                    }
828                    Err(err) => {
829                        // Fail-fast: stop starting new siblings; keep draining
830                        // in-flight ones so the lowest call-index terminator wins.
831                        terminating.store(true, std::sync::atomic::Ordering::SeqCst);
832                        if first_error.as_ref().is_none_or(|(i, _)| index < *i) {
833                            first_error = Some((index, err));
834                        }
835                    }
836                }
837            }
838        }
839
840        // Settle. On termination: surface only the deterministic error — no
841        // execution commit, no result, no history commit (all-or-nothing).
842        if let Some((_, err)) = first_error {
843            yield Err(StreamingError::Prompt(Box::new(err)));
844            return;
845        }
846
847        // Success: prepare each call's stream items and results in call order,
848        // commit the results, then surface the buffered items. An executed call
849        // surfaces `ToolExecutionCommitted`
850        // (with the effective, hook-rewritten call) then its `ToolResult`; a
851        // hook-skipped call surfaces its `ToolResult` only (nothing ran); a
852        // preresolved call surfaces nothing (already surfaced during the model
853        // turn) but is still committed. Every non-dropped slot is filled; a
854        // dropped slot only occurs after a termination, handled above.
855        let mut committed: Vec<UserContent> = Vec::with_capacity(call_count);
856        let mut surface_items: Vec<MultiTurnStreamItem> =
857            Vec::with_capacity(call_count.saturating_mul(2));
858        for slot in collected {
859            let CollectedToolResult { content, internal_call_id, surface } = match slot {
860                Some(collected_result) => collected_result,
861                None => {
862                    yield Err(StreamingError::Prompt(Box::new(PromptError::CompletionError(
863                        CompletionError::ResponseError(
864                            "tool execution finished without producing every result".to_string(),
865                        ),
866                    ))));
867                    return;
868                }
869            };
870            if forward_items {
871                // An executed call also surfaces its execution commit; a skipped
872                // call surfaces only its result; a preresolved call surfaces
873                // nothing here.
874                let surface_result = match surface {
875                    ToolSurface::Executed(tool_call) => {
876                        surface_items.push(MultiTurnStreamItem::ToolExecutionCommitted {
877                            tool_call: *tool_call,
878                            internal_call_id: internal_call_id.clone(),
879                        });
880                        true
881                    }
882                    ToolSurface::Skipped => true,
883                    ToolSurface::Preresolved => false,
884                };
885                if surface_result
886                    && let UserContent::ToolResult(tool_result) = &content
887                {
888                    surface_items.push(MultiTurnStreamItem::StreamUserItem(
889                        StreamedUserContent::ToolResult {
890                            tool_result: tool_result.clone(),
891                            internal_call_id,
892                        },
893                    ));
894                }
895            }
896            committed.push(content);
897        }
898
899        if let Err(err) = run.tool_results(committed) {
900            yield Err(Box::new(err).into());
901            return;
902        }
903
904        for item in surface_items {
905            yield Ok(item);
906        }
907    })
908}
909
910/// [`TurnSource`] for the streaming surface: each turn opens a provider stream,
911/// drives a [`StreamedTurnAssembler`], and yields assistant/tool deltas.
912pub(crate) struct StreamingTurnSource {
913    /// The raw provider choice of the most recent turn; the final response
914    /// surfaces it as-is, even when canonical reordering was recorded in history.
915    last_final_choice: Vec<AssistantContent>,
916    last_message_id: Option<String>,
917    /// Resolved agent name, kept only for the empty-turn diagnostic warning.
918    agent_name: String,
919    /// Whether we created the agent span (vs. adopting a caller's ambient span);
920    /// gates recording `gen_ai.completion` onto it, matching the blocking source
921    /// so neither surface pollutes a caller-supplied span.
922    created_agent_span: bool,
923    /// Whether sensitive run-level prompt and completion content may be recorded.
924    record_telemetry_content: bool,
925    /// Hot-path interest gates, computed once: skip building/dispatching the
926    /// high-frequency delta events when no hook observes them.
927    observes_text_delta: bool,
928    observes_reasoning_delta: bool,
929    observes_tool_call_delta: bool,
930    /// Whether any hook is present — gates building the (history-cloning)
931    /// invalid-tool diagnostic context.
932    has_hooks: bool,
933}
934
935impl StreamingTurnSource {
936    pub(crate) fn new(
937        hooks: &HookStack,
938        agent_name: String,
939        created_agent_span: bool,
940        record_telemetry_content: bool,
941    ) -> Self {
942        Self {
943            // Nothing has streamed yet, so the last final choice is nothing.
944            // This was a fabricated empty-text part for want of an empty
945            // representation; `is_empty_assistant_turn` treated it as empty
946            // anyway, so the two are equivalent — this one is just honest.
947            last_final_choice: Vec::new(),
948            last_message_id: None,
949            agent_name,
950            created_agent_span,
951            record_telemetry_content,
952            observes_text_delta: hooks.observes(StepEventKind::TextDelta),
953            observes_reasoning_delta: hooks.observes(StepEventKind::ReasoningDelta),
954            observes_tool_call_delta: hooks.observes(StepEventKind::ToolCallDelta),
955            has_hooks: !hooks.is_empty(),
956        }
957    }
958
959    /// Record a completed model turn's canonical output onto the agent and
960    /// chat spans. Only self-created agent spans receive `gen_ai.completion`,
961    /// so neither surface pollutes a caller-supplied span.
962    fn record_turn_telemetry(
963        &self,
964        agent_span: &tracing::Span,
965        chat_span: &tracing::Span,
966        choice: &[AssistantContent],
967        record_content: bool,
968    ) {
969        if self.created_agent_span && self.record_telemetry_content {
970            agent_span.record("gen_ai.completion", assistant_text_from_choice(choice));
971        }
972        rig_core::telemetry::record_model_output(chat_span, choice, record_content);
973    }
974}
975
976impl TurnSource for StreamingTurnSource {
977    fn open_chat_span(
978        &self,
979        runner: &AgentRunner,
980        effective_preamble: Option<&str>,
981    ) -> tracing::Span {
982        build_chat_span!(runner, effective_preamble, "chat_streaming", "chat")
983    }
984
985    fn run_model_turn<'a>(
986        &'a mut self,
987        runner: &'a AgentRunner,
988        hook_ctx: &'a HookContext,
989        run: &'a mut AgentRun,
990        prepared: PreparedCompletionRequest,
991        chat_span: tracing::Span,
992        agent_span: &'a tracing::Span,
993        current_prompt: Message,
994    ) -> DriveStream<'a> {
995        Box::pin(async_stream::stream! {
996            // Bound before the builder is consumed, exactly as the blocking
997            // surface does: the cap this attempt was prepared with, patches
998            // included. Both surfaces read it from the same carrier, so they
999            // cannot report different numbers for the same attempt.
1000            let attempt_max_tokens = prepared.max_tokens;
1001
1002            let mut stream = match prepared
1003                .builder
1004                .stream()
1005                .instrument(chat_span.clone())
1006                .await
1007            {
1008                Ok(stream) => stream,
1009                Err(err) => {
1010                    yield Err(err.into());
1011                    return;
1012                }
1013            };
1014            // Captured from each completion-call emission so the normalized
1015            // `ModelTurnFinished` event carries the turn's usage.
1016            let mut last_usage = crate::completion::Usage::new();
1017
1018            let mut assembler = StreamedTurnAssembler::new(
1019                prepared.executable_tool_names.clone(),
1020                prepared.allowed_tool_names.clone(),
1021            );
1022            let mut completion_call_emitted = false;
1023            let mut turn_abandoned = false;
1024            let mut provider_final_seen = false;
1025            let mut pending_final = None;
1026            // Mirrors the blocking driver's `response_hook_suppressed`: a turn
1027            // whose invalid tool call was repaired is a recovered turn, so its
1028            // response-finish hook is suppressed.
1029            let mut turn_recovered = false;
1030
1031            // Emit the turn's single `CompletionCall` exactly once, recording its
1032            // usage onto the chat span and into the run. Defined here (not a free
1033            // fn) so it captures `completion_call_emitted`/`chat_span`/`run`; the
1034            // `yield` stays at each call site because `async_stream::stream!`
1035            // cannot see a `yield` produced inside a nested macro expansion.
1036            // Returns the item to yield (`Some` the first time, `None` after), or
1037            // the terminal error to surface.
1038            macro_rules! emit_completion_call {
1039                ($usage:expr) => {{
1040                    // Same source as identity below: the provider's terminal
1041                    // record. A path that never saw one yields `None`, which is
1042                    // "the provider reported no reason" — not "the turn stopped
1043                    // normally".
1044                    let reason = stream
1045                        .response
1046                        .as_ref()
1047                        .and_then(|response| response.finish_reason.clone());
1048                    emit_completion_call!($usage, reason)
1049                }};
1050                ($usage:expr, $finish_reason:expr) => {{
1051                    let usage = $usage;
1052                    last_usage = usage;
1053                    if !completion_call_emitted {
1054                        chat_span.record_token_usage(&usage);
1055                        // The terminal record (when the provider delivered
1056                        // one) carries this attempt's identity metadata — and
1057                        // its captured raw payload, read from the same
1058                        // terminal so the recorded call carries *this*
1059                        // attempt's response, never a previous attempt's.
1060                        match run.record_streamed_completion_call(
1061                            usage,
1062                            stream.identity(),
1063                            $finish_reason,
1064                            stream
1065                                .response
1066                                .as_ref()
1067                                .map_or(serde_json::Value::Null, |response| response.raw.clone()),
1068                        ) {
1069                            Ok(call) => {
1070                                completion_call_emitted = true;
1071                                Ok(Some(MultiTurnStreamItem::CompletionCall(call)))
1072                            }
1073                            Err(err) => Err(Box::new(err).into()),
1074                        }
1075                    } else {
1076                        Ok(None)
1077                    }
1078                }};
1079            }
1080
1081            'turn: while let Some(item) = stream.next().await {
1082                let item = match item {
1083                    Ok(item) => item,
1084                    Err(err) => {
1085                        yield Err(err.into());
1086                        return;
1087                    }
1088                };
1089                if provider_final_seen {
1090                    yield Err(CompletionError::ResponseError(
1091                        "provider stream emitted visible assistant content after its final response"
1092                            .to_string(),
1093                    )
1094                    .into());
1095                    return;
1096                }
1097                let mut events: VecDeque<StreamedTurnEvent> = match assembler.ingest(&item) {
1098                    Ok(events) => events.into(),
1099                    Err(err) => {
1100                        yield Err(err.into());
1101                        return;
1102                    }
1103                };
1104                // At most one event per ingested item forwards the item itself;
1105                // moving it out of the slot avoids a clone per streamed delta.
1106                let mut item_slot = Some(item);
1107                while let Some(event) = events.pop_front() {
1108                    match event {
1109                        StreamedTurnEvent::EmitIngested => {
1110                            if self.observes_text_delta
1111                                && let Some(StreamedAssistantContent::Text(text)) =
1112                                    item_slot.as_ref()
1113                                && let Some(reason) = observe_action(
1114                                    runner
1115                                        .config.hooks
1116                                        .on_text_delta(
1117                                            hook_ctx,
1118                                            TextDelta {
1119                                                delta: &text.text,
1120                                                aggregated: assembler.aggregated_text(),
1121                                            },
1122                                        )
1123                                        .await,
1124                                )
1125                            {
1126                                yield Err(StreamingError::Prompt(Box::new(
1127                                    run.cancel_error(reason),
1128                                )));
1129                                return;
1130                            }
1131                            if self.observes_reasoning_delta
1132                                && let Some(StreamedAssistantContent::ReasoningDelta {
1133                                    id,
1134                                    provider_id,
1135                                    reasoning,
1136                                }) = item_slot.as_ref()
1137                            {
1138                                let Some(aggregated) = assembler.aggregated_reasoning(id) else {
1139                                    yield Err(CompletionError::ResponseError(format!(
1140                                        "reasoning delta `{id}` was ingested without a pending aggregate"
1141                                    ))
1142                                    .into());
1143                                    return;
1144                                };
1145                                if let Some(reason) = observe_action(
1146                                    runner
1147                                        .config.hooks
1148                                        .on_reasoning_delta(
1149                                            hook_ctx,
1150                                            ReasoningDelta {
1151                                                id,
1152                                                provider_id: provider_id.as_deref(),
1153                                                delta: reasoning,
1154                                                aggregated,
1155                                            },
1156                                        )
1157                                        .await,
1158                                ) {
1159                                    yield Err(StreamingError::Prompt(Box::new(
1160                                        run.cancel_error(reason),
1161                                    )));
1162                                    return;
1163                                }
1164                            }
1165                            if let Some(item) = item_slot.take() {
1166                                yield Ok(MultiTurnStreamItem::stream_item(item));
1167                            }
1168                        }
1169                        StreamedTurnEvent::EmitToolCallDelta {
1170                            internal_call_id,
1171                            content,
1172                        } => {
1173                            if self.observes_tool_call_delta {
1174                                let (delta_name, delta_text) = match &content {
1175                                    ToolCallDeltaContent::Name(name) => (Some(name.as_str()), ""),
1176                                    ToolCallDeltaContent::Delta(delta) => (None, delta.as_str()),
1177                                };
1178                                if let Some(reason) = observe_action(
1179                                    runner
1180                                        .config.hooks
1181                                        .on_tool_call_delta(
1182                                            hook_ctx,
1183                                            ToolCallDelta {
1184                                                internal_call_id: &internal_call_id,
1185                                                tool_name: delta_name,
1186                                                delta: delta_text,
1187                                            },
1188                                        )
1189                                        .await,
1190                                ) {
1191                                    yield Err(StreamingError::Prompt(Box::new(
1192                                        run.cancel_error(reason),
1193                                    )));
1194                                    return;
1195                                }
1196                            }
1197
1198                            yield Ok(MultiTurnStreamItem::StreamAssistantItem(
1199                                StreamedAssistantContent::ToolCallDelta {
1200                                    internal_call_id,
1201                                    content,
1202                                },
1203                            ));
1204                        }
1205                        StreamedTurnEvent::Completed {
1206                            usage,
1207                            emit_final,
1208                            finish_reason,
1209                        } => {
1210                            match emit_completion_call!(usage, finish_reason) {
1211                                Ok(Some(item)) => yield Ok(item),
1212                                Ok(None) => {}
1213                                Err(err) => {
1214                                    yield Err(err);
1215                                    return;
1216                                }
1217                            }
1218                            provider_final_seen = true;
1219
1220                            if emit_final
1221                                && matches!(
1222                                    item_slot.as_ref(),
1223                                    Some(StreamedAssistantContent::Final(_))
1224                                )
1225                            {
1226                                pending_final = item_slot.take();
1227                            }
1228                        }
1229                        StreamedTurnEvent::InvalidToolCall(invalid) => {
1230                            let partial = assembler.partial_turn(stream.message_id.clone());
1231                            // Gated on `has_hooks`: building the diagnostic context
1232                            // clones the chat history, so an empty stack skips it and
1233                            // fails fast — identical to the blocking path.
1234                            let action = if self.has_hooks {
1235                                let context =
1236                                    run.streamed_invalid_tool_call_context(&partial, &invalid);
1237                                runner
1238                                    .config.hooks
1239                                    .on_invalid_tool_call(hook_ctx, &context)
1240                                    .await
1241                                    .unwrap_or_else(InvalidToolCallAction::fail)
1242                            } else {
1243                                InvalidToolCallAction::fail()
1244                            };
1245
1246                            let resolution =
1247                                match run.resolve_streamed_invalid_tool_call(&partial, &invalid, action) {
1248                                    Ok(resolution) => resolution,
1249                                    Err(err) => {
1250                                        yield Err(Box::new(err).into());
1251                                        return;
1252                                    }
1253                                };
1254
1255                            match resolution {
1256                                StreamedResolution::Repaired { .. } => {
1257                                    // Replayed deltas flow through the same event
1258                                    // handling above; the turn is now recovered, so
1259                                    // its response-finish hook is suppressed.
1260                                    turn_recovered = true;
1261                                    events.extend(assembler.resolve_pending_invalid(&resolution));
1262                                }
1263                                StreamedResolution::TurnAbandoned {
1264                                    ref skipped_tool_result,
1265                                } => {
1266                                    let skipped_tool_result = skipped_tool_result.clone();
1267                                    assembler.resolve_pending_invalid(&resolution);
1268
1269                                    if let Some(err) = assembler.pending_delta_error() {
1270                                        yield Err(err.into());
1271                                        return;
1272                                    }
1273                                    let drained_usage = match drain_stream_usage(&mut stream).await {
1274                                        Ok(usage) => usage,
1275                                        Err(err) => {
1276                                            yield Err(err);
1277                                            return;
1278                                        }
1279                                    };
1280                                    match emit_completion_call!(drained_usage) {
1281                                        Ok(Some(item)) => yield Ok(item),
1282                                        Ok(None) => {}
1283                                        Err(err) => {
1284                                            yield Err(err);
1285                                            return;
1286                                        }
1287                                    }
1288                                    if let Some(tool_result) = skipped_tool_result {
1289                                        yield Ok(MultiTurnStreamItem::StreamUserItem(
1290                                            StreamedUserContent::ToolResult {
1291                                                tool_result: *tool_result,
1292                                                internal_call_id: invalid.internal_call_id.clone(),
1293                                            },
1294                                        ));
1295                                    }
1296                                    turn_abandoned = true;
1297                                    break 'turn;
1298                                }
1299                            }
1300                        }
1301                    }
1302                }
1303            }
1304
1305            if turn_abandoned {
1306                return;
1307            }
1308
1309            // The provider stream ended without its terminal record. Per the
1310            // emission contract (`rig_core::streaming`), that absence means
1311            // truncation and must never be treated as a successful zero-usage
1312            // completion: reject the turn before any usage fallback, assembly,
1313            // history mutation, or tool dispatch can occur.
1314            if !provider_final_seen {
1315                yield Err(CompletionError::ResponseError(
1316                    "provider stream ended without a terminal record; treating the turn as truncated"
1317                        .to_string(),
1318                )
1319                .into());
1320                return;
1321            }
1322
1323            if let Some(err) = assembler.pending_delta_error() {
1324                yield Err(err.into());
1325                return;
1326            }
1327
1328            // Final fallback: no usage was ever learned, so there is nothing to
1329            // record onto the span (zero usage is the missing-metrics sentinel)
1330            // and this is the last read of the flag — kept inline (not
1331            // `emit_completion_call!`) so it doesn't emit a dead
1332            // `completion_call_emitted = true` write, which `unused_assignments`
1333            // rejects. Identity comes from the same accessor the macro uses, so
1334            // `completion_calls` and hook observations agree on this path too.
1335            if !completion_call_emitted {
1336                let fallback_finish_reason = stream
1337                    .response
1338                    .as_ref()
1339                    .and_then(|response| response.finish_reason.clone());
1340                match run.record_streamed_completion_call(
1341                    crate::completion::Usage::new(),
1342                    stream.identity(),
1343                    fallback_finish_reason,
1344                    stream
1345                        .response
1346                        .as_ref()
1347                        .map_or(serde_json::Value::Null, |response| response.raw.clone()),
1348                ) {
1349                    Ok(call) => yield Ok(MultiTurnStreamItem::CompletionCall(call)),
1350                    Err(err) => {
1351                        yield Err(Box::new(err).into());
1352                        return;
1353                    }
1354                }
1355            }
1356
1357            let final_turn_content = stream.choice.clone();
1358            let streamed_turn = assembler.finish(stream.message_id.clone(), &final_turn_content);
1359            // This attempt's identity, read from *this* stream's terminal
1360            // record (each attempt — including a retry — opens its own
1361            // stream, so a previous attempt's ids can never leak in). The
1362            // message id prefers the assembled turn's, which folds in an
1363            // explicit `MessageId` event; the terminal's ids fill the rest.
1364            let identity = rig_core::completion::ResponseIdentity {
1365                message_id: streamed_turn.message_id.clone(),
1366                ..stream.identity()
1367            };
1368            // This attempt's raw payload, from the same terminal record as the
1369            // identity above — so a retry never observes a previous attempt's
1370            // response. `Null` when no terminal record arrived.
1371            let attempt_raw = stream
1372                .response
1373                .as_ref()
1374                .map_or(&serde_json::Value::Null, |response| &response.raw);
1375            if pending_final.is_some()
1376                && !turn_recovered
1377                && let Some(reason) = observe_action(
1378                    runner
1379                        .config.hooks
1380                        .on_stream_response_finish(
1381                            hook_ctx,
1382                            StreamResponseFinish {
1383                                prompt: &current_prompt,
1384                                content: &streamed_turn.choice,
1385                                usage: last_usage,
1386                                message_id: streamed_turn.message_id.as_deref(),
1387                                identity: &identity,
1388                                raw: attempt_raw,
1389                            },
1390                        )
1391                        .await,
1392                )
1393            {
1394                yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
1395                return;
1396            }
1397            self.last_message_id = streamed_turn.message_id.clone();
1398            // The canonical assistant content: `finish` normalizes
1399            // reasoning/text/tool ordering, so this can differ from the raw
1400            // `stream.choice` aggregate. `ModelTurnFinished` — the normalized
1401            // per-turn event — carries this, matching what is recorded into run
1402            // history; the raw `stream.choice` is kept in `last_final_choice` for
1403            // the raw/final streaming behavior.
1404            let canonical_choice = streamed_turn.choice.clone();
1405            // Captured for the same reason as the choice above: `streamed_turn`
1406            // is moved into run state on the next line, and the per-turn hook
1407            // fires after that. `FinishReason::Other` carries a `String`, so
1408            // this is a clone rather than a copy.
1409            let attempt_finish_reason = streamed_turn.finish_reason.clone();
1410            if let Err(err) = run.streamed_turn(streamed_turn) {
1411                yield Err(Box::new(err).into());
1412                return;
1413            }
1414            // Normalized per-turn event, fired once the turn is parked for
1415            // acceptance on the streaming surface — including tool-only /
1416            // reasoning-only turns that fire no `StreamResponseFinish`.
1417            // Suppressed for recovered turns, mirroring the blocking surface's
1418            // `Continue` arm.
1419            if !turn_recovered {
1420                let action = runner
1421                    .config.hooks
1422                    .on_model_turn_finished(
1423                        hook_ctx,
1424                        ModelTurnFinished {
1425                            turn: hook_ctx.turn(),
1426                            content: &canonical_choice,
1427                            usage: last_usage,
1428                            identity: &identity,
1429                            finish_reason: attempt_finish_reason.as_ref(),
1430                            max_tokens: attempt_max_tokens,
1431                            raw: attempt_raw,
1432                        },
1433                    )
1434                    .await;
1435                match resolve_model_turn_action(run, action) {
1436                    Ok(ModelTurnDecision::Advance) => {}
1437                    Ok(ModelTurnDecision::Retried) => {
1438                        yield Ok(MultiTurnStreamItem::ModelTurnRetried {
1439                            turn: hook_ctx.turn(),
1440                        });
1441                        return;
1442                    }
1443                    Ok(ModelTurnDecision::Terminate(reason)) => {
1444                        // Before model-turn steering was added, Stop observed
1445                        // this already completed provider turn: its buffered
1446                        // final and content telemetry were visible before the
1447                        // cancellation. Preserve that behavior while Retry
1448                        // alone suppresses the provisional final.
1449                        self.record_turn_telemetry(
1450                            agent_span,
1451                            &chat_span,
1452                            &canonical_choice,
1453                            runner.config.record_telemetry_content,
1454                        );
1455                        if let Some(item) = pending_final.take() {
1456                            yield Ok(MultiTurnStreamItem::stream_item(item));
1457                        }
1458                        yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
1459                        return;
1460                    }
1461                    Err(err) => {
1462                        yield Err(StreamingError::Prompt(Box::new(err)));
1463                        return;
1464                    }
1465                }
1466            }
1467
1468            // Only hook-accepted canonical output belongs in content telemetry.
1469            // Keep caller-owned spans untouched, matching the blocking source.
1470            self.record_turn_telemetry(
1471                agent_span,
1472                &chat_span,
1473                &canonical_choice,
1474                runner.config.record_telemetry_content,
1475            );
1476
1477            if let Some(item) = pending_final {
1478                yield Ok(MultiTurnStreamItem::stream_item(item));
1479            }
1480            self.last_final_choice = final_turn_content;
1481        })
1482    }
1483
1484    fn run_tool_calls<'a>(
1485        &'a self,
1486        runner: &'a AgentRunner,
1487        hook_ctx: &'a HookContext,
1488        run: &'a mut AgentRun,
1489        calls: Vec<PendingToolCall>,
1490        tool_snapshot: Arc<ToolRegistrySnapshot>,
1491    ) -> DriveStream<'a> {
1492        // The streaming surface chains nothing onto its tool spans, and forwards
1493        // the ToolCall/ToolResult items to the consumer.
1494        drive_tool_calls(
1495            runner,
1496            hook_ctx,
1497            run,
1498            calls,
1499            tool_snapshot,
1500            |span| span,
1501            true,
1502        )
1503    }
1504
1505    fn record_run_level_telemetry(
1506        &self,
1507        agent_span: &tracing::Span,
1508        response: &PromptResponse,
1509        created_agent_span: bool,
1510    ) {
1511        if created_agent_span {
1512            agent_span.record_token_usage(&response.usage);
1513        }
1514    }
1515
1516    fn final_item(&self, response: &PromptResponse) -> Option<MultiTurnStreamItem> {
1517        // Tool output mode (#1928): when the finishing turn made the output-tool
1518        // call, surface the run's structured output as the final content.
1519        let final_choice = finalize_streamed_choice(&self.last_final_choice, &response.output)
1520            .unwrap_or_else(|| {
1521                if is_empty_assistant_turn(&self.last_final_choice) {
1522                    tracing::warn!(
1523                        agent_name = self.agent_name.as_str(),
1524                        message_id = ?self.last_message_id,
1525                        "Streaming turn completed without assistant text; final response will be empty"
1526                    );
1527                }
1528                self.last_final_choice.clone()
1529            });
1530        // Always surface the accumulated messages (parity with the blocking
1531        // `run()`), regardless of whether the caller supplied input history.
1532        let final_messages: Option<Vec<Message>> =
1533            Some(response.messages.clone().unwrap_or_default());
1534        Some(MultiTurnStreamItem::final_response_with_completion_calls(
1535            final_choice,
1536            response.usage,
1537            response.completion_calls.clone(),
1538            final_messages,
1539        ))
1540    }
1541}
1542
1543impl AgentRunner {
1544    /// Drive the agent loop, streaming assistant content, tool activity, and a
1545    /// final response. Hooks fire at every observable point, including streamed
1546    /// text and tool-call deltas. Returns the stream after loading any
1547    /// configured conversation memory.
1548    ///
1549    /// Shares the drive loop, run construction, tool execution and fail-closed
1550    /// hook handling with the blocking [`run`](AgentRunner::run) via
1551    /// `drive_agent`, so the two behave identically apart from the streamed
1552    /// delta events.
1553    pub async fn stream(self) -> StreamingResult {
1554        let (agent_span, created_agent_span) = self.open_agent_span();
1555
1556        let (history_override, memory_handle) = match self.resolve_history_and_memory().await {
1557            Ok(resolved) => resolved,
1558            Err(err) => {
1559                let stream = async_stream::stream! {
1560                    yield Err(StreamingError::from(err));
1561                };
1562                // Instrument under the agent span like the success path so
1563                // a load failure stays tied to invoke_agent.
1564                return Box::pin(stream.instrument(agent_span));
1565            }
1566        };
1567
1568        let run = self.build_run(history_override);
1569        let source = StreamingTurnSource::new(
1570            &self.config.hooks,
1571            self.agent_name_or_default().to_string(),
1572            created_agent_span,
1573            self.config.record_telemetry_content,
1574        );
1575
1576        // The blocking surface folds this same engine; the streaming surface
1577        // forwards intermediate items (the final response item is the last one)
1578        // and ends on `Done`.
1579        let driver = drive_agent(
1580            self,
1581            source,
1582            run,
1583            agent_span.clone(),
1584            created_agent_span,
1585            memory_handle,
1586            true,
1587        )
1588        .filter_map(|item| {
1589            std::future::ready(match item {
1590                Ok(DriveItem::Item(item)) => Some(Ok(item)),
1591                Ok(DriveItem::Done(_)) => None,
1592                Err(err) => Some(Err(err)),
1593            })
1594        });
1595
1596        Box::pin(driver.instrument(agent_span))
1597    }
1598}
1599
1600impl IntoFuture for StreamingPromptRequest {
1601    type Output = StreamingResult; // what `.await` returns
1602    type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
1603
1604    fn into_future(self) -> Self::IntoFuture {
1605        // Wrap send() in a future, because send() returns a stream immediately
1606        Box::pin(async move { self.send().await })
1607    }
1608}
1609
1610/// Helper function to stream assistant-visible completion output to stdout.
1611///
1612/// This helper prints streamed assistant text and reasoning. Streaming metadata
1613/// events, such as `MultiTurnStreamItem::CompletionCall`, are not printed;
1614/// metadata is returned on the [`PromptResponse`] via accessors such as
1615/// [`PromptResponse::completion_calls`]. A model-turn retry prints a visible
1616/// boundary because text already written to stdout cannot be retracted.
1617pub async fn stream_to_stdout(
1618    stream: &mut StreamingResult,
1619) -> Result<PromptResponse, std::io::Error> {
1620    let mut final_res = PromptResponse::empty();
1621    print!("Response: ");
1622    while let Some(content) = stream.next().await {
1623        match content {
1624            Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
1625                Text { text, .. },
1626            ))) => {
1627                print!("{text}");
1628                std::io::Write::flush(&mut std::io::stdout())?;
1629            }
1630            Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning {
1631                reasoning,
1632                ..
1633            })) => {
1634                let reasoning = reasoning.display_text();
1635                print!("{reasoning}");
1636                std::io::Write::flush(&mut std::io::stdout())?;
1637            }
1638            Ok(MultiTurnStreamItem::FinalResponse(res)) => {
1639                final_res = res;
1640            }
1641            Ok(MultiTurnStreamItem::ModelTurnRetried { turn }) => {
1642                print!("\n[model turn {turn} rejected; retry requested]\nResponse: ");
1643                std::io::Write::flush(&mut std::io::stdout())?;
1644            }
1645            Err(err) => {
1646                eprintln!("Error: {err}");
1647            }
1648            _ => {}
1649        }
1650    }
1651
1652    Ok(final_res)
1653}
1654
1655#[cfg(test)]
1656#[allow(irrefutable_let_patterns, unreachable_patterns)]
1657mod migrated_tests {
1658    use crate::agent::{
1659        InvalidToolCallAction, InvalidToolCallContext, ModelTurnAction, ModelTurnFinished,
1660        ObservationAction, ReasoningDelta, StepEventKind, StreamResponseFinish, TextDelta,
1661        ToolCall, ToolCallAction, ToolCallDelta,
1662    };
1663
1664    use super::*;
1665    use crate::agent::AgentBuilder;
1666    use crate::agent::hook::{AgentHook, HookContext};
1667    use crate::agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_output};
1668    use crate::client::AgentClientExt;
1669    use crate::completion::{
1670        CompletionRequest, FinishReason, Prompt, PromptError, ToolDefinition, Usage,
1671    };
1672    use crate::streaming::{StreamingPrompt, ToolCallDeltaContent};
1673    use crate::test_utils::{
1674        AppendFailingMemory, FailingMemory, MockAddTool, MockBarrierTool, MockCompletionModel,
1675        MockContextProbeTool, MockStreamEvent, MockSubtractTool, MockToolError, MockTurn,
1676        SessionId, mock_final,
1677    };
1678    use crate::tool::{Tool, ToolContext};
1679    use futures::{StreamExt, TryStreamExt};
1680    use rig_core::client::ProviderClient;
1681    use rig_core::message::{
1682        AssistantContent, DocumentSourceKind, ImageMediaType, Message, ReasoningContent,
1683        ToolChoice, ToolResultContent, UserContent,
1684    };
1685    use rig_core::providers::anthropic;
1686    use serde::Deserialize;
1687    use std::collections::{BTreeSet, HashMap};
1688    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1689    use std::sync::{Arc, Mutex};
1690    use std::time::Duration;
1691    use tracing::field::{Field, Visit};
1692    use tracing::{Id, Subscriber};
1693    use tracing_subscriber::layer::{Context, SubscriberExt};
1694    use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
1695
1696    struct StopAgentStreamingBeforeCompletion;
1697
1698    impl AgentHook for StopAgentStreamingBeforeCompletion {
1699        async fn on_completion_call(
1700            &self,
1701            _ctx: &HookContext,
1702            _event: crate::agent::CompletionCallEvent<'_>,
1703        ) -> crate::agent::CompletionCallAction {
1704            crate::agent::CompletionCallAction::stop("agent streaming stopped")
1705        }
1706    }
1707
1708    #[tokio::test]
1709    async fn public_streaming_request_constructor_preserves_agent_hooks() {
1710        let model = MockCompletionModel::from_stream_turns([[
1711            MockStreamEvent::text("should not run"),
1712            MockStreamEvent::final_response(Usage::new()),
1713        ]]);
1714        let agent = Arc::new(
1715            AgentBuilder::new(model.clone())
1716                .add_hook(StopAgentStreamingBeforeCompletion)
1717                .build(),
1718        );
1719
1720        let mut stream = StreamingPromptRequest::new(agent, "go").await;
1721        let error = stream
1722            .try_next()
1723            .await
1724            .expect_err("the configured agent hook should terminate the stream");
1725
1726        assert!(matches!(
1727            error,
1728            StreamingError::Prompt(error)
1729                if matches!(*error, PromptError::PromptCancelled { ref reason, .. }
1730                    if reason == "agent streaming stopped")
1731        ));
1732        assert_eq!(model.request_count(), 0);
1733    }
1734
1735    #[tokio::test]
1736    async fn text_only_stream_without_terminal_record_is_rejected_as_truncated() {
1737        let model =
1738            MockCompletionModel::from_stream_turns([[MockStreamEvent::text("partial answer")]]);
1739        let agent = Arc::new(AgentBuilder::new(model.clone()).build());
1740
1741        let mut stream = StreamingPromptRequest::new(agent, "go").await;
1742        let mut saw_error = false;
1743        let mut saw_completion_call = false;
1744        while let Some(item) = stream.next().await {
1745            match item {
1746                Err(error) => {
1747                    assert!(
1748                        error.to_string().contains("terminal record"),
1749                        "truncation should surface as a terminal-record error, got: {error}"
1750                    );
1751                    saw_error = true;
1752                    break;
1753                }
1754                Ok(MultiTurnStreamItem::CompletionCall(_)) => saw_completion_call = true,
1755                Ok(_) => {}
1756            }
1757        }
1758        assert!(
1759            saw_error,
1760            "a stream ending without a terminal record must be rejected, not \
1761             treated as a successful completion"
1762        );
1763        // The rejection happens before any usage fallback records the call, so
1764        // the runner never produces a `CompletionCall` whose `raw` is `Null`:
1765        // a `Null` payload can only come from a hand-driven `AgentRun`.
1766        assert!(
1767            !saw_completion_call,
1768            "no completion call may be recorded for a truncated stream"
1769        );
1770    }
1771
1772    #[tokio::test]
1773    async fn tool_call_stream_without_terminal_record_dispatches_no_tools() {
1774        let calls = Arc::new(AtomicU32::new(0));
1775        let add_tool = CountingAddTool {
1776            calls: calls.clone(),
1777        };
1778        let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::tool_call(
1779            "tool_call_1",
1780            "add",
1781            serde_json::json!({"x": 1, "y": 2}),
1782        )]]);
1783        let agent = AgentBuilder::new(model.clone()).tool(add_tool).build();
1784
1785        let mut stream = agent.stream_prompt("go").max_turns(3).await;
1786        let mut saw_error = false;
1787        while let Some(item) = stream.next().await {
1788            if item.is_err() {
1789                saw_error = true;
1790                break;
1791            }
1792        }
1793        assert!(
1794            saw_error,
1795            "a truncated tool-call turn must error rather than complete"
1796        );
1797        assert_eq!(
1798            calls.load(Ordering::SeqCst),
1799            0,
1800            "a tool call from a stream the provider never confirmed complete \
1801             must not be dispatched"
1802        );
1803    }
1804
1805    #[test]
1806    fn finalize_streamed_choice_surfaces_output_over_tool_call_and_prose() {
1807        use rig_core::message::{ToolCall, ToolFunction};
1808
1809        let output_call = AssistantContent::ToolCall(ToolCall::from_wire(
1810            "c1",
1811            ToolFunction::new(
1812                "final_result".to_string(),
1813                serde_json::json!({"city": "Tokyo"}),
1814            ),
1815        ));
1816
1817        // Prose + output-tool call (#1928): the streamed response text must be
1818        // the structured output, not the prose, with no orphan tool_use.
1819        let with_prose = vec![
1820            AssistantContent::text("Sure, here is the weather:"),
1821            output_call.clone(),
1822        ];
1823        let final_choice = finalize_streamed_choice(&with_prose, r#"{"city":"Tokyo"}"#)
1824            .expect("a turn with the output-tool call is finalized via it");
1825        assert_eq!(
1826            assistant_text_from_choice(&final_choice),
1827            r#"{"city":"Tokyo"}"#
1828        );
1829        assert!(
1830            !final_choice
1831                .iter()
1832                .any(|item| matches!(item, AssistantContent::ToolCall(_))),
1833            "no unanswered tool_use should remain in the final content"
1834        );
1835
1836        // Output-tool call only.
1837        let only_call = vec![output_call];
1838        let final_choice = finalize_streamed_choice(&only_call, r#"{"city":"Tokyo"}"#)
1839            .expect("finalized via output tool");
1840        assert_eq!(
1841            assistant_text_from_choice(&final_choice),
1842            r#"{"city":"Tokyo"}"#
1843        );
1844
1845        // A plain-text finalize (no tool call) is left to the caller.
1846        let text_only = vec![AssistantContent::text(r#"{"city":"Tokyo"}"#)];
1847        assert!(finalize_streamed_choice(&text_only, r#"{"city":"Tokyo"}"#).is_none());
1848    }
1849
1850    #[test]
1851    fn tool_result_output_preserves_multimodal_tool_output() {
1852        let instruction = serde_json::json!({
1853            "instruction": "Use the image part to answer."
1854        });
1855        let mut content = vec![ToolResultContent::json(instruction.clone())];
1856        content.push(ToolResultContent::image_base64(
1857            "base64data==",
1858            Some(ImageMediaType::PNG),
1859            None,
1860        ));
1861        let user_content = tool_result_output(
1862            rig_core::message::ToolCallId::new_or_mint("tool_call_1"),
1863            rig_core::message::ProviderCallId::new("call_1"),
1864            "render_reference_image".to_string(),
1865            crate::tool::ToolOutput::content(content).expect("fixture content is non-empty"),
1866        );
1867
1868        let tool_result = match user_content {
1869            UserContent::ToolResult(tool_result) => tool_result,
1870            other => panic!("expected tool result content, got {other:?}"),
1871        };
1872
1873        assert_eq!(tool_result.call, "tool_call_1");
1874        assert_eq!(
1875            tool_result
1876                .provider
1877                .as_ref()
1878                .map(|provider| provider.call_id.as_str()),
1879            Some("call_1")
1880        );
1881        assert_eq!(tool_result.content.len(), 2);
1882
1883        let mut items = tool_result.content.iter();
1884        match items.next() {
1885            Some(ToolResultContent::Json { value }) => {
1886                assert_eq!(value, &instruction);
1887            }
1888            other => panic!("expected structured JSON payload first, got {other:?}"),
1889        }
1890
1891        match items.next() {
1892            Some(ToolResultContent::Image(image)) => {
1893                assert_eq!(image.media_type, Some(ImageMediaType::PNG));
1894                assert!(matches!(
1895                    image.data,
1896                    DocumentSourceKind::Base64(ref data) if data == "base64data=="
1897                ));
1898            }
1899            other => panic!("expected image payload second, got {other:?}"),
1900        }
1901    }
1902
1903    fn validate_follow_up_tool_history(request: &CompletionRequest) -> Result<(), String> {
1904        let history = request.chat_history.clone();
1905        if history.len() != 3 {
1906            return Err(format!(
1907                "follow-up request should contain [original user prompt, assistant tool call, user tool result]: {history:?}"
1908            ));
1909        }
1910
1911        if !matches!(
1912            history.first(),
1913            Some(Message::User { content })
1914                if matches!(
1915                    content.first(),
1916                    Some(UserContent::Text(text)) if text.text == "do tool work"
1917                )
1918        ) {
1919            return Err(format!(
1920                "follow-up request should begin with the original user prompt: {history:?}"
1921            ));
1922        }
1923
1924        // The stream issued both an item id ("tool_call_1") and a correlator
1925        // ("call_1"): the correlator drives rig's durable id, and both travel
1926        // on `provider` as the dual-wire identifiers.
1927        if !matches!(
1928            history.get(1),
1929            Some(Message::Assistant { content, .. })
1930                if matches!(
1931                    content.first(),
1932                    Some(AssistantContent::ToolCall(tool_call))
1933                        if tool_call.id == "call_1"
1934                            && tool_call.provider.as_ref().is_some_and(|provider| {
1935                                provider.call_id == "call_1"
1936                                    && provider.item_id.as_deref() == Some("tool_call_1")
1937                            })
1938                )
1939        ) {
1940            return Err(format!(
1941                "follow-up request is missing the assistant tool call in position 2: {history:?}"
1942            ));
1943        }
1944
1945        if !matches!(
1946            history.get(2),
1947            Some(Message::User { content })
1948                if matches!(
1949                    content.first(),
1950                    Some(UserContent::ToolResult(tool_result))
1951                        if tool_result.call == "call_1"
1952                            && tool_result.provider.as_ref().is_some_and(|provider| {
1953                                provider.call_id == "call_1"
1954                                    && provider.item_id.as_deref() == Some("tool_call_1")
1955                            })
1956                )
1957        ) {
1958            return Err(format!(
1959                "follow-up request should end with the user tool result: {history:?}"
1960            ));
1961        }
1962
1963        Ok(())
1964    }
1965
1966    fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1967        history.iter().any(|message| {
1968            matches!(
1969                message,
1970                Message::Assistant { content, .. }
1971                    if content.iter().any(|item| matches!(
1972                        item,
1973                        AssistantContent::ToolCall(tool_call)
1974                            if tool_call.function.name == tool_name
1975                    ))
1976            )
1977        })
1978    }
1979
1980    /// The invalid-call retry transcript pairs 1:1 by construction: every tool
1981    /// call in the assistant turn carries a unique non-empty id (minted at the
1982    /// provider boundary when the wire issued none), and the retry results
1983    /// answer exactly those ids.
1984    fn assert_retry_transcript_ids_pair(assistant: &Message, results: &Message) {
1985        let Message::Assistant { content, .. } = assistant else {
1986            panic!("expected the assistant tool-call turn, got {assistant:?}");
1987        };
1988        let call_ids: Vec<&str> = content
1989            .iter()
1990            .filter_map(|item| match item {
1991                AssistantContent::ToolCall(tool_call) => Some(tool_call.id.as_str()),
1992                _ => None,
1993            })
1994            .collect();
1995        let Message::User { content } = results else {
1996            panic!("expected the user retry-result turn, got {results:?}");
1997        };
1998        let result_ids: Vec<&str> = content
1999            .iter()
2000            .filter_map(|item| match item {
2001                UserContent::ToolResult(result) => Some(result.call.as_str()),
2002                _ => None,
2003            })
2004            .collect();
2005        assert!(
2006            call_ids.iter().all(|id| !id.is_empty()),
2007            "every tool call carries a non-empty id: {call_ids:?}"
2008        );
2009        let unique_calls: BTreeSet<&str> = call_ids.iter().copied().collect();
2010        assert_eq!(
2011            unique_calls.len(),
2012            call_ids.len(),
2013            "tool-call ids must be unique: {call_ids:?}"
2014        );
2015        let unique_results: BTreeSet<&str> = result_ids.iter().copied().collect();
2016        assert_eq!(
2017            unique_results.len(),
2018            result_ids.len(),
2019            "retry-result ids must be unique: {result_ids:?}"
2020        );
2021        assert_eq!(
2022            unique_calls, unique_results,
2023            "retry results must answer exactly the turn's tool calls"
2024        );
2025    }
2026
2027    fn history_contains_text(history: &[Message], expected: &str) -> bool {
2028        history.iter().any(|message| {
2029            matches!(
2030                message,
2031                Message::Assistant { content, .. }
2032                    if content.iter().any(|item| matches!(
2033                        item,
2034                        AssistantContent::Text(text) if text.text == expected
2035                    ))
2036            )
2037        })
2038    }
2039
2040    fn assistant_reasoning_precedes_tool_call(
2041        history: &[Message],
2042        expected_reasoning: &str,
2043        tool_name: &str,
2044    ) -> bool {
2045        history.iter().any(|message| {
2046            let Message::Assistant { content, .. } = message else {
2047                return false;
2048            };
2049
2050            let reasoning_index = content.iter().position(|item| {
2051                matches!(
2052                    item,
2053                    AssistantContent::Reasoning(reasoning)
2054                        if reasoning.content.iter().any(|content| matches!(
2055                            content,
2056                            ReasoningContent::Text { text, .. }
2057                                if text == expected_reasoning
2058                        ))
2059                )
2060            });
2061            let tool_index = content.iter().position(|item| {
2062                matches!(
2063                    item,
2064                    AssistantContent::ToolCall(tool_call)
2065                        if tool_call.function.name == tool_name
2066                )
2067            });
2068
2069            matches!((reasoning_index, tool_index), (Some(reasoning), Some(tool)) if reasoning < tool)
2070        })
2071    }
2072
2073    fn assistant_reasoning_precedes_text_and_tool_call(
2074        history: &[Message],
2075        expected_reasoning: &str,
2076        expected_text: &str,
2077        tool_name: &str,
2078    ) -> bool {
2079        history.iter().any(|message| {
2080            let Message::Assistant { content, .. } = message else {
2081                return false;
2082            };
2083
2084            let reasoning_index = content.iter().position(|item| {
2085                matches!(
2086                    item,
2087                    AssistantContent::Reasoning(reasoning)
2088                        if reasoning.content.iter().any(|content| matches!(
2089                            content,
2090                            ReasoningContent::Text { text, .. }
2091                                if text == expected_reasoning
2092                        ))
2093                )
2094            });
2095            let text_index = content.iter().position(|item| {
2096                matches!(
2097                    item,
2098                    AssistantContent::Text(text) if text.text == expected_text
2099                )
2100            });
2101            let tool_index = content.iter().position(|item| {
2102                matches!(
2103                    item,
2104                    AssistantContent::ToolCall(tool_call)
2105                        if tool_call.function.name == tool_name
2106                )
2107            });
2108
2109            matches!(
2110                (reasoning_index, text_index, tool_index),
2111                (Some(reasoning), Some(text), Some(tool))
2112                    if reasoning < text && text < tool
2113            )
2114        })
2115    }
2116
2117    #[derive(Clone)]
2118    struct PanicOnUnknownToolHook;
2119
2120    impl AgentHook for PanicOnUnknownToolHook {
2121        async fn on_tool_call_delta(
2122            &self,
2123            _: &HookContext,
2124            _: ToolCallDelta<'_>,
2125        ) -> ObservationAction {
2126            panic!("unknown tool call delta should fail before delta hooks run")
2127        }
2128        async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2129            panic!("unknown tool call should fail before tool hooks run")
2130        }
2131        async fn on_stream_response_finish(
2132            &self,
2133            _: &HookContext,
2134            _: StreamResponseFinish<'_>,
2135        ) -> ObservationAction {
2136            panic!("unknown tool call should fail before stream finish hooks run")
2137        }
2138    }
2139
2140    #[derive(Clone)]
2141    struct CountingAddTool {
2142        calls: Arc<AtomicU32>,
2143    }
2144
2145    #[derive(Clone)]
2146    struct CountingSubtractTool {
2147        calls: Arc<AtomicU32>,
2148    }
2149
2150    #[derive(Deserialize)]
2151    struct CountingOperationArgs {
2152        x: i32,
2153        y: i32,
2154    }
2155
2156    fn arithmetic_tool_definition(name: &str, description: &str) -> ToolDefinition {
2157        ToolDefinition {
2158            name: name.to_string(),
2159            description: description.to_string(),
2160            parameters: serde_json::json!({
2161                "type": "object",
2162                "properties": {
2163                    "x": {
2164                        "type": "number",
2165                        "description": "The first operand"
2166                    },
2167                    "y": {
2168                        "type": "number",
2169                        "description": "The second operand"
2170                    }
2171                },
2172                "required": ["x", "y"],
2173            }),
2174        }
2175    }
2176
2177    impl Tool for CountingAddTool {
2178        const NAME: &'static str = "add";
2179        type Error = MockToolError;
2180        type Args = CountingOperationArgs;
2181        type Output = i32;
2182
2183        fn description(&self) -> String {
2184            "Add x and y together".to_string()
2185        }
2186
2187        fn parameters(&self) -> serde_json::Value {
2188            arithmetic_tool_definition(Self::NAME, "Add x and y together").parameters
2189        }
2190
2191        async fn call(
2192            &self,
2193            _context: &mut ToolContext,
2194            args: Self::Args,
2195        ) -> Result<Self::Output, Self::Error> {
2196            self.calls.fetch_add(1, Ordering::SeqCst);
2197            Ok(args.x + args.y)
2198        }
2199    }
2200
2201    impl Tool for CountingSubtractTool {
2202        const NAME: &'static str = "subtract";
2203        type Error = MockToolError;
2204        type Args = CountingOperationArgs;
2205        type Output = i32;
2206
2207        fn description(&self) -> String {
2208            "Subtract y from x".to_string()
2209        }
2210
2211        fn parameters(&self) -> serde_json::Value {
2212            arithmetic_tool_definition(Self::NAME, "Subtract y from x").parameters
2213        }
2214
2215        async fn call(
2216            &self,
2217            _context: &mut ToolContext,
2218            args: Self::Args,
2219        ) -> Result<Self::Output, Self::Error> {
2220            self.calls.fetch_add(1, Ordering::SeqCst);
2221            Ok(args.x - args.y)
2222        }
2223    }
2224
2225    fn streaming_tool_then_text_model() -> MockCompletionModel {
2226        MockCompletionModel::from_stream_turns([
2227            vec![
2228                MockStreamEvent::tool_call(
2229                    "tool_call_1",
2230                    "add",
2231                    serde_json::json!({"x": 1, "y": 2}),
2232                )
2233                .with_call_id("call_1"),
2234                MockStreamEvent::final_response_with_total_tokens(4),
2235            ],
2236            vec![
2237                MockStreamEvent::text("done"),
2238                MockStreamEvent::final_response_with_total_tokens(6),
2239            ],
2240        ])
2241    }
2242
2243    /// The record a streamed mock turn scripted with
2244    /// `MockStreamEvent::final_response(usage)` leaves on `completion_calls`:
2245    /// index, usage, and the mock's terminal record serialized onto `raw` —
2246    /// the terminal is always captured, so an expected call without it never
2247    /// matches.
2248    fn streamed_call(call_index: usize, usage: Usage) -> CompletionCall {
2249        let terminal = mock_final(usage);
2250        CompletionCall::new(call_index, usage)
2251            .with_raw(serde_json::to_value(&terminal).expect("mock terminal serializes"))
2252    }
2253
2254    fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
2255        Usage {
2256            input_tokens,
2257            output_tokens,
2258            total_tokens: input_tokens + output_tokens,
2259            cached_input_tokens: 0,
2260            cache_creation_input_tokens: 0,
2261            tool_use_prompt_tokens: 0,
2262            reasoning_tokens: 0,
2263        }
2264    }
2265
2266    #[tokio::test]
2267    async fn execution_commit_items_are_not_emitted_when_run_commit_fails() {
2268        let runner = AgentBuilder::new(MockCompletionModel::default())
2269            .build()
2270            .runner("go");
2271        let tool_snapshot = Arc::new(
2272            runner
2273                .tool_server_handle
2274                .snapshot_tool_defs(None)
2275                .await
2276                .expect("empty tool snapshot should build"),
2277        );
2278
2279        let mut run = AgentRun::new("go").max_turns(2);
2280        assert!(matches!(
2281            run.next_step().expect("initial model step"),
2282            AgentRunStep::CallModel { .. }
2283        ));
2284
2285        let tool_name = "missing".to_string();
2286        let advertised = BTreeSet::from([tool_name.clone()]);
2287        let turn = crate::agent::run::ModelTurn::new(
2288            None,
2289            vec![AssistantContent::ToolCall(
2290                rig_core::message::ToolCall::new(
2291                    rig_core::message::ToolCallId::new_or_mint("expected_call"),
2292                    rig_core::message::ToolFunction::new(tool_name, serde_json::json!({})),
2293                ),
2294            )],
2295            Usage::new(),
2296            advertised.clone(),
2297            advertised,
2298        );
2299        assert!(matches!(
2300            run.model_response(turn)
2301                .expect("tool turn should be accepted"),
2302            crate::agent::run::ModelTurnOutcome::Continue { .. }
2303        ));
2304
2305        let mut calls = match run.next_step().expect("tool step") {
2306            AgentRunStep::CallTools { calls } => calls,
2307            other => panic!("expected tool step, got {other:?}"),
2308        };
2309        // Corrupt only the driver's copy so execution settles successfully but
2310        // `AgentRun` rejects the result before any commit-labelled item escapes.
2311        calls[0].tool_call.id = rig_core::message::ToolCallId::new_or_mint("mismatched_call");
2312
2313        let hook_context = HookContext::new(true, None);
2314        hook_context.set_turn(1);
2315        let mut stream = drive_tool_calls(
2316            &runner,
2317            &hook_context,
2318            &mut run,
2319            calls,
2320            tool_snapshot,
2321            |span| span,
2322            true,
2323        );
2324
2325        let mut saw_commit = false;
2326        let mut saw_result = false;
2327        let mut saw_error = false;
2328        while let Some(item) = stream.next().await {
2329            match item {
2330                Ok(MultiTurnStreamItem::ToolExecutionCommitted { .. }) => saw_commit = true,
2331                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2332                    ..
2333                })) => saw_result = true,
2334                Err(_) => saw_error = true,
2335                _ => {}
2336            }
2337        }
2338
2339        assert!(
2340            saw_error,
2341            "the mismatched result must fail run-state commit"
2342        );
2343        assert!(!saw_commit, "a failed run-state commit cannot be announced");
2344        assert!(!saw_result, "an uncommitted result cannot be surfaced");
2345    }
2346
2347    #[derive(Clone, Debug, Default)]
2348    struct CapturedSpan {
2349        id: u64,
2350        name: String,
2351        parent_id: Option<u64>,
2352        fields: HashMap<String, u64>,
2353        string_fields: HashMap<String, String>,
2354        record_counts: HashMap<String, usize>,
2355    }
2356
2357    #[derive(Clone, Default)]
2358    struct CapturedSpans(Arc<Mutex<Vec<CapturedSpan>>>);
2359
2360    impl CapturedSpans {
2361        fn clear(&self) {
2362            if let Ok(mut spans) = self.0.lock() {
2363                spans.clear();
2364            }
2365        }
2366
2367        fn insert(&self, id: &Id, name: &str, parent_id: Option<u64>) {
2368            let id = id.into_u64();
2369            if let Ok(mut spans) = self.0.lock() {
2370                spans.push(CapturedSpan {
2371                    id,
2372                    name: name.to_string(),
2373                    parent_id,
2374                    fields: HashMap::new(),
2375                    string_fields: HashMap::new(),
2376                    record_counts: HashMap::new(),
2377                });
2378            }
2379        }
2380
2381        fn record(&self, id: &Id, fields: Vec<CapturedField>) {
2382            if let Ok(mut spans) = self.0.lock()
2383                && let Some(span) = spans.iter_mut().rev().find(|span| span.id == id.into_u64())
2384            {
2385                for field in fields {
2386                    match field {
2387                        CapturedField::Number(name, value) => {
2388                            *span.record_counts.entry(name.clone()).or_insert(0) += 1;
2389                            span.fields.insert(name, value);
2390                        }
2391                        CapturedField::Text(name, value) => {
2392                            *span.record_counts.entry(name.clone()).or_insert(0) += 1;
2393                            span.fields.insert(name.clone(), 0);
2394                            span.string_fields.insert(name, value);
2395                        }
2396                    }
2397                }
2398            }
2399        }
2400
2401        fn record_strings(&self, id: &Id, fields: Vec<(String, String)>) {
2402            if let Ok(mut spans) = self.0.lock()
2403                && let Some(span) = spans.iter_mut().rev().find(|span| span.id == id.into_u64())
2404            {
2405                span.string_fields.extend(fields);
2406            }
2407        }
2408
2409        fn snapshot(&self) -> Vec<CapturedSpan> {
2410            self.0.lock().map(|spans| spans.clone()).unwrap_or_default()
2411        }
2412    }
2413
2414    struct SpanCaptureLayer {
2415        spans: CapturedSpans,
2416    }
2417
2418    impl<S> Layer<S> for SpanCaptureLayer
2419    where
2420        S: Subscriber,
2421        S: for<'lookup> LookupSpan<'lookup>,
2422    {
2423        fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
2424            let parent_id = attrs
2425                .parent()
2426                .map(Id::into_u64)
2427                .or_else(|| ctx.current_span().id().map(Id::into_u64));
2428            self.spans.insert(id, attrs.metadata().name(), parent_id);
2429            let mut string_fields = Vec::new();
2430            attrs.record(&mut SpanStringCaptureVisitor {
2431                fields: &mut string_fields,
2432            });
2433            self.spans.record_strings(id, string_fields);
2434        }
2435
2436        fn on_record(&self, span: &Id, values: &tracing::span::Record<'_>, _ctx: Context<'_, S>) {
2437            let mut fields = Vec::new();
2438            values.record(&mut SpanFieldCaptureVisitor {
2439                fields: &mut fields,
2440            });
2441            self.spans.record(span, fields);
2442            let mut string_fields = Vec::new();
2443            values.record(&mut SpanStringCaptureVisitor {
2444                fields: &mut string_fields,
2445            });
2446            self.spans.record_strings(span, string_fields);
2447        }
2448    }
2449
2450    enum CapturedField {
2451        Number(String, u64),
2452        Text(String, String),
2453    }
2454
2455    struct SpanFieldCaptureVisitor<'a> {
2456        fields: &'a mut Vec<CapturedField>,
2457    }
2458
2459    struct SpanStringCaptureVisitor<'a> {
2460        fields: &'a mut Vec<(String, String)>,
2461    }
2462
2463    impl Visit for SpanStringCaptureVisitor<'_> {
2464        fn record_str(&mut self, field: &Field, value: &str) {
2465            self.fields
2466                .push((field.name().to_string(), value.to_string()));
2467        }
2468
2469        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2470            self.fields
2471                .push((field.name().to_string(), format!("{value:?}")));
2472        }
2473    }
2474
2475    impl Visit for SpanFieldCaptureVisitor<'_> {
2476        fn record_u64(&mut self, field: &Field, value: u64) {
2477            self.fields
2478                .push(CapturedField::Number(field.name().to_string(), value));
2479        }
2480
2481        // Capture the *presence* of non-numeric fields (e.g. `gen_ai.completion`)
2482        // with a placeholder value so tests can assert whether they were recorded.
2483        fn record_str(&mut self, field: &Field, value: &str) {
2484            self.fields.push(CapturedField::Text(
2485                field.name().to_string(),
2486                value.to_string(),
2487            ));
2488        }
2489
2490        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2491            self.fields.push(CapturedField::Text(
2492                field.name().to_string(),
2493                format!("{value:?}"),
2494            ));
2495        }
2496    }
2497
2498    async fn assert_stream_usage_recorded_on_chat_spans(
2499        agent: crate::agent::Agent,
2500        prompt: &str,
2501        max_turns: usize,
2502        expected_usages: &[Usage],
2503    ) {
2504        // Scoped-subscriber tests must not run concurrently; the warm-up below
2505        // explains the callsite-interest hazard this guards against. The
2506        // guard's own docs carry that recipe plus the rule it cannot enforce:
2507        // an absence assertion needs a positive anchor, or it passes vacuously.
2508        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2509        let spans = CapturedSpans::default();
2510        let subscriber = Registry::default().with(SpanCaptureLayer {
2511            spans: spans.clone(),
2512        });
2513        let _default = tracing::subscriber::set_default(subscriber);
2514
2515        // Span callsites in the driver are shared with every other test in
2516        // this binary. The FIRST thread to hit a callsite caches its interest
2517        // from that thread's dispatcher (`Dispatchers::Rebuilder::JustOne`
2518        // consults `dispatcher::get_default`), so a parallel test without a
2519        // subscriber can permanently cache `Interest::never` for the very
2520        // spans this harness asserts on. Defend in two steps, both under the
2521        // isolation guard: (1) warm the whole driver path from THIS thread so
2522        // unregistered callsites first-register against this subscriber, then
2523        // (2) rebuild the interest cache to heal callsites a foreign thread
2524        // already poisoned.
2525        let warmup_model = MockCompletionModel::from_stream_turns([[
2526            MockStreamEvent::text("warmup"),
2527            MockStreamEvent::final_response(Usage::default()),
2528        ]]);
2529        let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2530        let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2531        while let Some(item) = warmup_stream
2532            .try_next()
2533            .await
2534            .expect("warmup stream should not error")
2535        {
2536            if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2537                break;
2538            }
2539        }
2540        tracing::callsite::rebuild_interest_cache();
2541        spans.clear();
2542
2543        let empty_history: &[Message] = &[];
2544        // Declare the fields the guard protects so a regression (recording onto
2545        // a caller span) is actually observable, not silently a no-op.
2546        let outer_span = tracing::info_span!("outer", gen_ai.completion = tracing::field::Empty);
2547
2548        async {
2549            let mut stream = agent
2550                .stream_prompt(prompt)
2551                .history(empty_history)
2552                .max_turns(max_turns)
2553                .await;
2554
2555            while let Some(item) = stream.try_next().await.expect("stream should not error") {
2556                if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2557                    break;
2558                }
2559            }
2560        }
2561        .instrument(outer_span)
2562        .await;
2563
2564        let span_snapshot = spans.snapshot();
2565        let outer_span_id = span_snapshot
2566            .iter()
2567            .find(|span| span.name == "outer")
2568            .map(|span| span.id)
2569            .expect("outer span should be captured");
2570        let chat_spans = span_snapshot
2571            .iter()
2572            .filter(|span| span.name == "chat_streaming")
2573            .collect::<Vec<_>>();
2574
2575        assert_eq!(chat_spans.len(), expected_usages.len());
2576        assert!(
2577            span_snapshot.iter().all(|span| span.name != "invoke_agent"),
2578            "outer span path should not create invoke_agent"
2579        );
2580
2581        for (chat_span, expected_usage) in chat_spans.into_iter().zip(expected_usages) {
2582            assert_eq!(chat_span.parent_id, Some(outer_span_id));
2583            assert_eq!(
2584                chat_span
2585                    .string_fields
2586                    .get("gen_ai.operation.name")
2587                    .map(String::as_str),
2588                Some("chat")
2589            );
2590            assert_eq!(
2591                chat_span.fields.get("gen_ai.usage.input_tokens"),
2592                Some(&expected_usage.input_tokens)
2593            );
2594            assert_eq!(
2595                chat_span.fields.get("gen_ai.usage.output_tokens"),
2596                Some(&expected_usage.output_tokens)
2597            );
2598            assert_eq!(
2599                chat_span.fields.get("gen_ai.usage.cache_read.input_tokens"),
2600                Some(&expected_usage.cached_input_tokens)
2601            );
2602            assert_eq!(
2603                chat_span
2604                    .fields
2605                    .get("gen_ai.usage.cache_creation.input_tokens"),
2606                Some(&expected_usage.cache_creation_input_tokens)
2607            );
2608            assert_eq!(
2609                chat_span.fields.get("gen_ai.usage.tool_use_prompt_tokens"),
2610                Some(&expected_usage.tool_use_prompt_tokens)
2611            );
2612            assert_eq!(
2613                chat_span.fields.get("gen_ai.usage.reasoning_tokens"),
2614                Some(&expected_usage.reasoning_tokens)
2615            );
2616        }
2617
2618        let outer_span = span_snapshot
2619            .iter()
2620            .find(|span| span.id == outer_span_id)
2621            .expect("outer span should be present");
2622        assert!(
2623            outer_span
2624                .fields
2625                .keys()
2626                .all(|field| !field.starts_with("gen_ai.usage.")),
2627            "usage should not be recorded onto the caller's outer span"
2628        );
2629        assert!(
2630            !outer_span.fields.contains_key("gen_ai.completion"),
2631            "gen_ai.completion should not be recorded onto the caller's outer span \
2632             (parity with the blocking driver)"
2633        );
2634    }
2635
2636    async fn capture_stream_message_telemetry(
2637        record_telemetry_content: bool,
2638    ) -> (CapturedSpan, Vec<CompletionRequest>) {
2639        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2640        let spans = CapturedSpans::default();
2641        let subscriber = Registry::default().with(SpanCaptureLayer {
2642            spans: spans.clone(),
2643        });
2644        let _default = tracing::subscriber::set_default(subscriber);
2645
2646        let warmup_model = MockCompletionModel::from_stream_turns([[
2647            MockStreamEvent::text("warmup"),
2648            MockStreamEvent::final_response(Usage::default()),
2649        ]]);
2650        let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2651        let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2652        while let Some(item) = warmup_stream
2653            .try_next()
2654            .await
2655            .expect("warmup stream should not error")
2656        {
2657            if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2658                break;
2659            }
2660        }
2661        tracing::callsite::rebuild_interest_cache();
2662        spans.clear();
2663
2664        let model = MockCompletionModel::from_stream_turns([[
2665            MockStreamEvent::text("stream response secret"),
2666            MockStreamEvent::final_response(Usage::default()),
2667        ]]);
2668        let recorded_model = model.clone();
2669        let builder = AgentBuilder::new(model);
2670        let agent = if record_telemetry_content {
2671            builder
2672                .record_content_telemetry(true)
2673                .context("static stream context secret")
2674                .build()
2675        } else {
2676            builder.context("static stream context secret").build()
2677        };
2678
2679        let mut stream = agent
2680            .stream_prompt("stream prompt secret")
2681            .max_turns(1)
2682            .await;
2683        while let Some(item) = stream.try_next().await.expect("stream should not error") {
2684            if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2685                break;
2686            }
2687        }
2688
2689        let span = spans
2690            .snapshot()
2691            .into_iter()
2692            .find(|span| span.name == "chat_streaming")
2693            .expect("chat_streaming span should be captured");
2694        (span, recorded_model.requests())
2695    }
2696
2697    async fn capture_unary_message_telemetry(
2698        record_telemetry_content: bool,
2699    ) -> (CapturedSpan, CapturedSpan, Vec<CompletionRequest>) {
2700        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2701        let spans = CapturedSpans::default();
2702        let subscriber = Registry::default().with(SpanCaptureLayer {
2703            spans: spans.clone(),
2704        });
2705        let _default = tracing::subscriber::set_default(subscriber);
2706
2707        let warmup_agent =
2708            crate::agent::AgentBuilder::new(MockCompletionModel::text("warmup")).build();
2709        warmup_agent
2710            .prompt("warmup")
2711            .await
2712            .expect("warmup prompt should not error");
2713        tracing::callsite::rebuild_interest_cache();
2714        spans.clear();
2715
2716        let model = MockCompletionModel::text("blocking response secret");
2717        let recorded_model = model.clone();
2718        let builder = AgentBuilder::new(model).preamble("blocking system secret");
2719        let agent = if record_telemetry_content {
2720            builder.record_content_telemetry(true).build()
2721        } else {
2722            builder.build()
2723        };
2724
2725        agent
2726            .prompt("blocking prompt secret")
2727            .await
2728            .expect("prompt should not error");
2729
2730        let snapshot = spans.snapshot();
2731        let chat_span = snapshot
2732            .iter()
2733            .find(|span| span.name == "chat")
2734            .cloned()
2735            .expect("chat span should be captured");
2736        let agent_span = snapshot
2737            .into_iter()
2738            .find(|span| span.name == "invoke_agent")
2739            .expect("invoke_agent span should be captured");
2740        (chat_span, agent_span, recorded_model.requests())
2741    }
2742
2743    #[tokio::test]
2744    async fn stream_prompt_message_telemetry_is_opt_in() {
2745        let (default_span, default_requests) = capture_stream_message_telemetry(false).await;
2746        assert!(
2747            !default_span.fields.contains_key("gen_ai.input.messages"),
2748            "default streaming prompt should not record input message contents"
2749        );
2750        assert!(
2751            !default_span.fields.contains_key("gen_ai.output.messages"),
2752            "default streaming prompt should not record output message contents"
2753        );
2754
2755        assert_eq!(default_requests.len(), 1);
2756        assert!(
2757            !default_requests[0].record_telemetry_content,
2758            "default agent stream should keep provider request message telemetry disabled"
2759        );
2760
2761        let (opt_in_span, opt_in_requests) = capture_stream_message_telemetry(true).await;
2762        let input = opt_in_span
2763            .string_fields
2764            .get("gen_ai.input.messages")
2765            .expect("opt-in should record input messages");
2766        assert!(input.contains("stream prompt secret"));
2767        assert!(input.contains("static stream context secret"));
2768        let output = opt_in_span
2769            .string_fields
2770            .get("gen_ai.output.messages")
2771            .expect("opt-in should record output messages");
2772        assert!(output.contains("stream response secret"));
2773        assert_eq!(
2774            opt_in_span
2775                .record_counts
2776                .get("gen_ai.input.messages")
2777                .copied(),
2778            Some(1),
2779            "agent-owned input message telemetry should be recorded once"
2780        );
2781        assert_eq!(
2782            opt_in_span
2783                .record_counts
2784                .get("gen_ai.output.messages")
2785                .copied(),
2786            Some(1),
2787            "agent-owned output message telemetry should be recorded once"
2788        );
2789        assert_eq!(opt_in_requests.len(), 1);
2790        assert!(
2791            !opt_in_requests[0].record_telemetry_content,
2792            "agent-owned stream telemetry should clear the provider request flag"
2793        );
2794    }
2795
2796    #[tokio::test]
2797    async fn unary_prompt_message_telemetry_records_accepted_output_when_opted_in() {
2798        let (default_span, default_agent_span, default_requests) =
2799            capture_unary_message_telemetry(false).await;
2800        assert!(
2801            !default_span.fields.contains_key("gen_ai.input.messages"),
2802            "default blocking prompt should not record input message contents"
2803        );
2804        assert!(
2805            !default_span.fields.contains_key("gen_ai.output.messages"),
2806            "default blocking prompt should not record output message contents"
2807        );
2808        assert!(
2809            !default_span
2810                .string_fields
2811                .contains_key("gen_ai.system_instructions"),
2812            "default blocking prompt should not record system instructions"
2813        );
2814        assert!(
2815            !default_agent_span
2816                .string_fields
2817                .contains_key("gen_ai.prompt")
2818        );
2819        assert!(
2820            !default_agent_span
2821                .string_fields
2822                .contains_key("gen_ai.completion")
2823        );
2824        assert_eq!(default_requests.len(), 1);
2825        assert!(
2826            !default_requests[0].record_telemetry_content,
2827            "default blocking prompt should keep provider request message telemetry disabled"
2828        );
2829
2830        let (opt_in_span, opt_in_agent_span, opt_in_requests) =
2831            capture_unary_message_telemetry(true).await;
2832        let input = opt_in_span
2833            .string_fields
2834            .get("gen_ai.input.messages")
2835            .expect("opt-in should record blocking input messages");
2836        assert!(input.contains("blocking prompt secret"));
2837        let output = opt_in_span
2838            .string_fields
2839            .get("gen_ai.output.messages")
2840            .expect("opt-in should record blocking output messages");
2841        assert!(output.contains("blocking response secret"));
2842        assert_eq!(
2843            opt_in_span
2844                .string_fields
2845                .get("gen_ai.system_instructions")
2846                .map(String::as_str),
2847            Some(r#"[{"type":"text","content":"blocking system secret"}]"#)
2848        );
2849        assert_eq!(
2850            opt_in_agent_span
2851                .string_fields
2852                .get("gen_ai.prompt")
2853                .map(String::as_str),
2854            Some("blocking prompt secret")
2855        );
2856        assert_eq!(
2857            opt_in_agent_span
2858                .string_fields
2859                .get("gen_ai.completion")
2860                .map(String::as_str),
2861            Some("blocking response secret")
2862        );
2863        assert_eq!(opt_in_requests.len(), 1);
2864        assert!(
2865            !opt_in_requests[0].record_telemetry_content,
2866            "agent-owned blocking telemetry should clear the provider request flag"
2867        );
2868    }
2869
2870    async fn capture_tool_content_telemetry(record_telemetry_content: bool) -> CapturedSpan {
2871        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2872        let spans = CapturedSpans::default();
2873        let subscriber = Registry::default().with(SpanCaptureLayer {
2874            spans: spans.clone(),
2875        });
2876        let _default = tracing::subscriber::set_default(subscriber);
2877
2878        let warmup = AgentBuilder::new(MockCompletionModel::from_turns([
2879            MockTurn::tool_call("warmup", "add", serde_json::json!({"x": 1, "y": 2})),
2880            MockTurn::text("done"),
2881        ]))
2882        .tool(MockAddTool)
2883        .build();
2884        warmup
2885            .runner("warmup")
2886            .max_turns(2)
2887            .run()
2888            .await
2889            .expect("warmup tool run should succeed");
2890        tracing::callsite::rebuild_interest_cache();
2891        spans.clear();
2892
2893        let builder = AgentBuilder::new(MockCompletionModel::from_turns([
2894            MockTurn::tool_call(
2895                "secret-tool-call",
2896                "add",
2897                serde_json::json!({"x": 12345, "y": 67890}),
2898            ),
2899            MockTurn::text("done"),
2900        ]))
2901        .tool(MockAddTool);
2902        let agent = if record_telemetry_content {
2903            builder.record_content_telemetry(true).build()
2904        } else {
2905            builder.build()
2906        };
2907        agent
2908            .runner("use the tool")
2909            .max_turns(2)
2910            .run()
2911            .await
2912            .expect("tool run should succeed");
2913
2914        spans
2915            .snapshot()
2916            .into_iter()
2917            .find(|span| span.name == "execute_tool")
2918            .expect("execute_tool span should be captured")
2919    }
2920
2921    #[tokio::test]
2922    async fn tool_arguments_and_results_follow_content_telemetry_toggle() {
2923        let default_span = capture_tool_content_telemetry(false).await;
2924        assert!(
2925            !default_span
2926                .string_fields
2927                .contains_key("gen_ai.tool.call.arguments")
2928        );
2929        assert!(
2930            !default_span
2931                .string_fields
2932                .contains_key("gen_ai.tool.call.result")
2933        );
2934        assert_eq!(
2935            default_span
2936                .string_fields
2937                .get("gen_ai.tool.name")
2938                .map(String::as_str),
2939            Some("add"),
2940            "structural tool metadata should remain available"
2941        );
2942
2943        let opt_in_span = capture_tool_content_telemetry(true).await;
2944        assert!(
2945            opt_in_span
2946                .string_fields
2947                .get("gen_ai.tool.call.arguments")
2948                .is_some_and(|args| args.contains("12345") && args.contains("67890"))
2949        );
2950        assert!(
2951            opt_in_span
2952                .string_fields
2953                .get("gen_ai.tool.call.result")
2954                .is_some_and(|result| result.contains("80235"))
2955        );
2956    }
2957
2958    #[tokio::test]
2959    async fn streaming_rejected_message_telemetry_does_not_record_output() {
2960        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2961        let spans = CapturedSpans::default();
2962        let subscriber = Registry::default().with(SpanCaptureLayer {
2963            spans: spans.clone(),
2964        });
2965        let _default = tracing::subscriber::set_default(subscriber);
2966
2967        let warmup_model = MockCompletionModel::from_stream_turns([[
2968            MockStreamEvent::text("warmup"),
2969            MockStreamEvent::final_response(Usage::default()),
2970        ]]);
2971        let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2972        let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2973        while let Some(item) = warmup_stream
2974            .try_next()
2975            .await
2976            .expect("warmup stream should not error")
2977        {
2978            if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2979                break;
2980            }
2981        }
2982        tracing::callsite::rebuild_interest_cache();
2983        spans.clear();
2984
2985        let model = MockCompletionModel::from_stream_turns([[
2986            MockStreamEvent::text("rejected stream output secret"),
2987            MockStreamEvent::tool_call(
2988                "tool_call_1",
2989                "default_api",
2990                serde_json::json!({"x": 2, "y": 3}),
2991            ),
2992            MockStreamEvent::final_response(Usage::default()),
2993        ]]);
2994        let agent = AgentBuilder::new(model)
2995            .record_content_telemetry(true)
2996            .build();
2997
2998        let mut stream = agent
2999            .stream_prompt("stream rejection prompt")
3000            .max_turns(1)
3001            .await;
3002        let err = loop {
3003            match stream.try_next().await {
3004                Ok(Some(_)) => continue,
3005                Ok(None) => panic!("rejected stream should error"),
3006                Err(err) => break err,
3007            }
3008        };
3009        assert!(
3010            err.to_string().contains("default_api"),
3011            "expected invalid tool error, got {err}"
3012        );
3013
3014        let chat_span = spans
3015            .snapshot()
3016            .into_iter()
3017            .find(|span| span.name == "chat_streaming")
3018            .expect("chat_streaming span should be captured");
3019        assert!(
3020            chat_span.fields.contains_key("gen_ai.input.messages"),
3021            "opt-in rejected stream should still record input messages"
3022        );
3023        assert!(
3024            !chat_span.fields.contains_key("gen_ai.output.messages"),
3025            "rejected streaming turn must not record output message contents"
3026        );
3027    }
3028
3029    #[tokio::test]
3030    async fn unary_repaired_message_telemetry_records_canonical_output() {
3031        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
3032        let spans = CapturedSpans::default();
3033        let subscriber = Registry::default().with(SpanCaptureLayer {
3034            spans: spans.clone(),
3035        });
3036        let _default = tracing::subscriber::set_default(subscriber);
3037
3038        let warmup_agent =
3039            crate::agent::AgentBuilder::new(MockCompletionModel::text("warmup")).build();
3040        warmup_agent
3041            .prompt("warmup")
3042            .await
3043            .expect("warmup prompt should not error");
3044        tracing::callsite::rebuild_interest_cache();
3045        spans.clear();
3046
3047        let model = MockCompletionModel::new([
3048            MockTurn::tool_call(
3049                "tool_call_1",
3050                "default_api",
3051                serde_json::json!({"x": 2, "y": 3}),
3052            ),
3053            MockTurn::text("done"),
3054        ]);
3055        let recorded_model = model.clone();
3056        let agent = AgentBuilder::new(model)
3057            .record_content_telemetry(true)
3058            .tool(MockAddTool)
3059            .build();
3060
3061        let output = agent
3062            .prompt("repair tool call")
3063            .add_hook(RepairDefaultApiHook)
3064            .max_turns(3)
3065            .await
3066            .expect("repaired tool call should complete");
3067        assert_eq!(output, "done");
3068
3069        let output_messages: Vec<String> = spans
3070            .snapshot()
3071            .into_iter()
3072            .filter(|span| span.name == "chat")
3073            .filter_map(|span| span.string_fields.get("gen_ai.output.messages").cloned())
3074            .collect();
3075        assert!(
3076            output_messages.iter().any(|output| output.contains("add")),
3077            "repaired accepted output should include canonical tool name: {output_messages:?}"
3078        );
3079        assert!(
3080            !output_messages
3081                .iter()
3082                .any(|output| output.contains("default_api")),
3083            "repaired output telemetry must not serialize stale raw tool name: {output_messages:?}"
3084        );
3085
3086        let requests = recorded_model.requests();
3087        assert_eq!(requests.len(), 2);
3088        assert!(
3089            requests
3090                .iter()
3091                .all(|request| !request.record_telemetry_content),
3092            "agent-owned repaired telemetry should clear provider request flags"
3093        );
3094    }
3095
3096    #[test]
3097    fn completion_calls_stream_item_serializes_and_deserializes_expected_shape() {
3098        let item: MultiTurnStreamItem =
3099            MultiTurnStreamItem::CompletionCall(CompletionCall::new(2, usage(3, 4)));
3100
3101        let value = serde_json::to_value(&item).expect("serialize completion call event");
3102
3103        assert_eq!(
3104            value,
3105            serde_json::json!({
3106                "type": "completionCall",
3107                "call_index": 2,
3108                "usage": {
3109                    "input_tokens": 3,
3110                    "output_tokens": 4,
3111                    "total_tokens": 7,
3112                    "cached_input_tokens": 0,
3113                    "cache_creation_input_tokens": 0,
3114                    "tool_use_prompt_tokens": 0,
3115                    "reasoning_tokens": 0,
3116                }
3117            })
3118        );
3119
3120        let item: MultiTurnStreamItem =
3121            serde_json::from_value(value).expect("deserialize completion call event");
3122        match item {
3123            MultiTurnStreamItem::CompletionCall(call_usage) => {
3124                assert_eq!(call_usage, CompletionCall::new(2, usage(3, 4)));
3125            }
3126            other => panic!("expected completion call event, got {other:?}"),
3127        }
3128
3129        let item: MultiTurnStreamItem =
3130            MultiTurnStreamItem::CompletionCall(CompletionCall::new(3, Usage::new()));
3131        let value = serde_json::to_value(&item).expect("serialize missing usage event");
3132
3133        // Unreported usage serializes as a plain zero-valued object (Usage's
3134        // documented sentinel for missing provider metrics).
3135        assert_eq!(
3136            value,
3137            serde_json::json!({
3138                "type": "completionCall",
3139                "call_index": 3,
3140                "usage": {
3141                    "input_tokens": 0,
3142                    "output_tokens": 0,
3143                    "total_tokens": 0,
3144                    "cached_input_tokens": 0,
3145                    "cache_creation_input_tokens": 0,
3146                    "tool_use_prompt_tokens": 0,
3147                    "reasoning_tokens": 0,
3148                }
3149            })
3150        );
3151
3152        // Stream items serialized before the Option encoding was dropped used
3153        // `"usage": null`; they must still deserialize.
3154        let legacy: MultiTurnStreamItem = serde_json::from_value(serde_json::json!({
3155            "type": "completionCall",
3156            "call_index": 3,
3157            "usage": null
3158        }))
3159        .expect("legacy null-usage event should deserialize");
3160        match legacy {
3161            MultiTurnStreamItem::CompletionCall(call) => {
3162                assert_eq!(call, CompletionCall::new(3, Usage::new()));
3163            }
3164            other => panic!("expected completion call event, got {other:?}"),
3165        }
3166    }
3167
3168    #[test]
3169    fn final_response_serializes_completion_calls_with_missing_usage() {
3170        let item: MultiTurnStreamItem = MultiTurnStreamItem::final_response_with_completion_calls(
3171            vec![AssistantContent::text("done")],
3172            usage(3, 4),
3173            vec![
3174                CompletionCall::new(0, Usage::new()),
3175                CompletionCall::new(1, usage(3, 4)),
3176            ],
3177            None,
3178        );
3179
3180        if let MultiTurnStreamItem::FinalResponse(response) = &item {
3181            assert_eq!(response.requests(), 2);
3182        }
3183
3184        let value = serde_json::to_value(&item).expect("serialize final response");
3185
3186        assert_eq!(
3187            value.get("completion_calls"),
3188            Some(&serde_json::json!([
3189                {
3190                    "call_index": 0,
3191                    "usage": {
3192                        "input_tokens": 0,
3193                        "output_tokens": 0,
3194                        "total_tokens": 0,
3195                        "cached_input_tokens": 0,
3196                        "cache_creation_input_tokens": 0,
3197                        "tool_use_prompt_tokens": 0,
3198                        "reasoning_tokens": 0,
3199                    }
3200                },
3201                {
3202                    "call_index": 1,
3203                    "usage": {
3204                        "input_tokens": 3,
3205                        "output_tokens": 4,
3206                        "total_tokens": 7,
3207                        "cached_input_tokens": 0,
3208                        "cache_creation_input_tokens": 0,
3209                        "tool_use_prompt_tokens": 0,
3210                        "reasoning_tokens": 0,
3211                    }
3212                }
3213            ]))
3214        );
3215    }
3216
3217    fn streaming_text_then_final_model() -> MockCompletionModel {
3218        MockCompletionModel::from_stream_turns([[
3219            MockStreamEvent::text("hello"),
3220            MockStreamEvent::text(" world"),
3221            MockStreamEvent::final_response_with_total_tokens(3),
3222        ]])
3223    }
3224
3225    fn citation_metadata() -> serde_json::Value {
3226        serde_json::json!({
3227            "citations": [{
3228                "type": "web_search_result_location",
3229                "cited_text": "Claude Shannon was born in 1916.",
3230                "url": "https://example.com/shannon",
3231                "title": "Claude Shannon",
3232                "encrypted_index": "encrypted-reference"
3233            }]
3234        })
3235    }
3236
3237    fn streaming_cited_text_then_final_model() -> MockCompletionModel {
3238        MockCompletionModel::from_stream_turns([[
3239            MockStreamEvent::text_start("block-0", Some(citation_metadata())),
3240            MockStreamEvent::text("cited "),
3241            MockStreamEvent::text_start("block-1", None),
3242            MockStreamEvent::text("answer"),
3243            MockStreamEvent::final_response_with_total_tokens(3),
3244        ]])
3245    }
3246
3247    fn streaming_cited_text_then_tool_model() -> MockCompletionModel {
3248        MockCompletionModel::from_stream_turns([
3249            vec![
3250                MockStreamEvent::text_start("block-0", Some(citation_metadata())),
3251                MockStreamEvent::text("I need a tool. "),
3252                MockStreamEvent::tool_call(
3253                    "tool_call_1",
3254                    "add",
3255                    serde_json::json!({"x": 1, "y": 2}),
3256                )
3257                .with_call_id("call_1"),
3258                MockStreamEvent::final_response_with_total_tokens(4),
3259            ],
3260            vec![
3261                MockStreamEvent::text("done"),
3262                MockStreamEvent::final_response_with_total_tokens(6),
3263            ],
3264        ])
3265    }
3266
3267    fn streaming_final_only_model() -> MockCompletionModel {
3268        MockCompletionModel::from_stream_turns([[
3269            MockStreamEvent::final_response_with_total_tokens(1),
3270        ]])
3271    }
3272
3273    #[derive(Clone)]
3274    struct TerminateOnStreamFinish;
3275
3276    impl AgentHook for TerminateOnStreamFinish {
3277        async fn on_stream_response_finish(
3278            &self,
3279            _ctx: &HookContext,
3280            event: StreamResponseFinish<'_>,
3281        ) -> ObservationAction {
3282            match event {
3283                StreamResponseFinish { .. } => {
3284                    ObservationAction::stop("stop after completion call")
3285                }
3286                _ => ObservationAction::continue_run(),
3287            }
3288        }
3289    }
3290
3291    type RecordedToolCallDelta = (String, Option<String>, String);
3292    type RecordedReasoningDelta = (String, Option<String>, String, String);
3293
3294    #[derive(Clone)]
3295    struct RepairDefaultApiHook;
3296
3297    impl AgentHook for RepairDefaultApiHook {
3298        async fn on_invalid_tool_call(
3299            &self,
3300            _ctx: &HookContext,
3301            event: &InvalidToolCallContext,
3302        ) -> Option<InvalidToolCallAction> {
3303            Some(match event {
3304                context => {
3305                    assert_eq!(context.tool_name, "default_api");
3306                    InvalidToolCallAction::repair("add")
3307                }
3308                _ => InvalidToolCallAction::fail(),
3309            })
3310        }
3311    }
3312
3313    #[derive(Clone)]
3314    struct RetryDefaultApiHook;
3315
3316    impl AgentHook for RetryDefaultApiHook {
3317        async fn on_invalid_tool_call(
3318            &self,
3319            _ctx: &HookContext,
3320            event: &InvalidToolCallContext,
3321        ) -> Option<InvalidToolCallAction> {
3322            Some(match event {
3323                context => {
3324                    assert_eq!(context.tool_name, "default_api");
3325                    if let Some(args) = context.args.as_deref() {
3326                        assert!(!args.is_empty());
3327                    }
3328                    InvalidToolCallAction::retry("Use the add tool instead")
3329                }
3330                _ => InvalidToolCallAction::fail(),
3331            })
3332        }
3333    }
3334
3335    #[derive(Clone)]
3336    struct SkipDefaultApiHook;
3337
3338    impl AgentHook for SkipDefaultApiHook {
3339        async fn on_invalid_tool_call(
3340            &self,
3341            _ctx: &HookContext,
3342            event: &InvalidToolCallContext,
3343        ) -> Option<InvalidToolCallAction> {
3344            Some(match event {
3345                context => {
3346                    assert_eq!(context.tool_name, "default_api");
3347                    InvalidToolCallAction::skip("default_api was skipped")
3348                }
3349                _ => InvalidToolCallAction::fail(),
3350            })
3351        }
3352    }
3353
3354    #[derive(Clone, Default)]
3355    struct RecordingInvalidToolCallHook {
3356        contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
3357    }
3358
3359    impl RecordingInvalidToolCallHook {
3360        fn observed(&self) -> Vec<InvalidToolCallContext> {
3361            self.contexts
3362                .lock()
3363                .expect("invalid tool context records mutex was poisoned")
3364                .clone()
3365        }
3366    }
3367
3368    impl AgentHook for RecordingInvalidToolCallHook {
3369        async fn on_invalid_tool_call(
3370            &self,
3371            _ctx: &HookContext,
3372            event: &InvalidToolCallContext,
3373        ) -> Option<InvalidToolCallAction> {
3374            Some(match event {
3375                context => {
3376                    self.contexts
3377                        .lock()
3378                        .expect("invalid tool context records mutex was poisoned")
3379                        .push(context.clone());
3380                    InvalidToolCallAction::fail()
3381                }
3382                _ => InvalidToolCallAction::fail(),
3383            })
3384        }
3385    }
3386
3387    #[derive(Clone, Default)]
3388    struct RecordingToolCallDeltaHook {
3389        deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
3390    }
3391
3392    impl RecordingToolCallDeltaHook {
3393        fn observed(&self) -> Vec<RecordedToolCallDelta> {
3394            self.deltas
3395                .lock()
3396                .expect("tool call delta hook records mutex was poisoned")
3397                .clone()
3398        }
3399    }
3400
3401    impl AgentHook for RecordingToolCallDeltaHook {
3402        async fn on_tool_call_delta(
3403            &self,
3404            _ctx: &HookContext,
3405            event: ToolCallDelta<'_>,
3406        ) -> ObservationAction {
3407            match event {
3408                ToolCallDelta {
3409                    internal_call_id,
3410                    tool_name,
3411                    delta,
3412                } => {
3413                    let record = (
3414                        internal_call_id.to_string(),
3415                        tool_name.map(str::to_string),
3416                        delta.to_string(),
3417                    );
3418                    self.deltas
3419                        .lock()
3420                        .expect("tool call delta hook records mutex was poisoned")
3421                        .push(record);
3422                    ObservationAction::continue_run()
3423                }
3424                _ => ObservationAction::continue_run(),
3425            }
3426        }
3427    }
3428
3429    #[derive(Clone, Default)]
3430    struct RecordingTextDeltaHook {
3431        deltas: Arc<Mutex<Vec<(String, String)>>>,
3432    }
3433
3434    impl RecordingTextDeltaHook {
3435        fn observed(&self) -> Vec<(String, String)> {
3436            self.deltas
3437                .lock()
3438                .expect("text delta hook records mutex was poisoned")
3439                .clone()
3440        }
3441    }
3442
3443    impl AgentHook for RecordingTextDeltaHook {
3444        async fn on_text_delta(
3445            &self,
3446            _ctx: &HookContext,
3447            event: TextDelta<'_>,
3448        ) -> ObservationAction {
3449            match event {
3450                TextDelta { delta, aggregated } => {
3451                    let record = (delta.to_string(), aggregated.to_string());
3452                    self.deltas
3453                        .lock()
3454                        .expect("text delta hook records mutex was poisoned")
3455                        .push(record);
3456                    ObservationAction::continue_run()
3457                }
3458                _ => ObservationAction::continue_run(),
3459            }
3460        }
3461    }
3462
3463    #[derive(Clone, Default)]
3464    struct RecordingReasoningDeltaHook {
3465        deltas: Arc<Mutex<Vec<RecordedReasoningDelta>>>,
3466    }
3467
3468    impl RecordingReasoningDeltaHook {
3469        fn observed(&self) -> Vec<RecordedReasoningDelta> {
3470            self.deltas
3471                .lock()
3472                .expect("reasoning delta hook records mutex was poisoned")
3473                .clone()
3474        }
3475    }
3476
3477    impl AgentHook for RecordingReasoningDeltaHook {
3478        async fn on_reasoning_delta(
3479            &self,
3480            _ctx: &HookContext,
3481            event: ReasoningDelta<'_>,
3482        ) -> ObservationAction {
3483            let record = (
3484                event.id.to_string(),
3485                event.provider_id.map(str::to_string),
3486                event.delta.to_string(),
3487                event.aggregated.to_string(),
3488            );
3489            self.deltas
3490                .lock()
3491                .expect("reasoning delta hook records mutex was poisoned")
3492                .push(record);
3493            ObservationAction::continue_run()
3494        }
3495    }
3496
3497    #[derive(Clone, Default)]
3498    struct TerminatingReasoningDeltaHook {
3499        recorder: RecordingReasoningDeltaHook,
3500    }
3501
3502    impl TerminatingReasoningDeltaHook {
3503        fn observed(&self) -> Vec<RecordedReasoningDelta> {
3504            self.recorder.observed()
3505        }
3506    }
3507
3508    impl AgentHook for TerminatingReasoningDeltaHook {
3509        async fn on_reasoning_delta(
3510            &self,
3511            ctx: &HookContext,
3512            event: ReasoningDelta<'_>,
3513        ) -> ObservationAction {
3514            self.recorder.on_reasoning_delta(ctx, event).await;
3515            ObservationAction::stop("stop on reasoning delta")
3516        }
3517    }
3518
3519    #[derive(Clone, Default)]
3520    struct UninterestedReasoningDeltaHook {
3521        calls: Arc<AtomicU32>,
3522    }
3523
3524    impl AgentHook for UninterestedReasoningDeltaHook {
3525        async fn on_reasoning_delta(
3526            &self,
3527            _ctx: &HookContext,
3528            _event: ReasoningDelta<'_>,
3529        ) -> ObservationAction {
3530            self.calls.fetch_add(1, Ordering::SeqCst);
3531            ObservationAction::stop("uninterested reasoning hook was dispatched")
3532        }
3533
3534        fn observes(&self, kind: StepEventKind) -> bool {
3535            kind != StepEventKind::ReasoningDelta
3536        }
3537    }
3538
3539    #[derive(Clone, Default)]
3540    struct RetryFirstReasoningTurnHook {
3541        recorder: RecordingReasoningDeltaHook,
3542        retried: Arc<AtomicBool>,
3543    }
3544
3545    impl AgentHook for RetryFirstReasoningTurnHook {
3546        async fn on_reasoning_delta(
3547            &self,
3548            ctx: &HookContext,
3549            event: ReasoningDelta<'_>,
3550        ) -> ObservationAction {
3551            self.recorder.on_reasoning_delta(ctx, event).await
3552        }
3553
3554        async fn on_model_turn_finished(
3555            &self,
3556            _ctx: &HookContext,
3557            _event: ModelTurnFinished<'_>,
3558        ) -> ModelTurnAction {
3559            if self.retried.swap(true, Ordering::SeqCst) {
3560                ModelTurnAction::continue_run()
3561            } else {
3562                ModelTurnAction::repeat()
3563            }
3564        }
3565    }
3566
3567    #[derive(Clone)]
3568    struct RecordingTextAndSkipInvalidToolHook {
3569        text: RecordingTextDeltaHook,
3570    }
3571
3572    impl AgentHook for RecordingTextAndSkipInvalidToolHook {
3573        async fn on_text_delta(
3574            &self,
3575            ctx: &HookContext,
3576            event: TextDelta<'_>,
3577        ) -> ObservationAction {
3578            self.text.on_text_delta(ctx, event).await
3579        }
3580        async fn on_invalid_tool_call(
3581            &self,
3582            ctx: &HookContext,
3583            event: &InvalidToolCallContext,
3584        ) -> Option<InvalidToolCallAction> {
3585            SkipDefaultApiHook.on_invalid_tool_call(ctx, event).await
3586        }
3587    }
3588
3589    #[derive(Clone)]
3590    struct RecordingTextAndRetryInvalidToolHook {
3591        text: RecordingTextDeltaHook,
3592    }
3593
3594    impl AgentHook for RecordingTextAndRetryInvalidToolHook {
3595        async fn on_text_delta(
3596            &self,
3597            ctx: &HookContext,
3598            event: TextDelta<'_>,
3599        ) -> ObservationAction {
3600            self.text.on_text_delta(ctx, event).await
3601        }
3602        async fn on_invalid_tool_call(
3603            &self,
3604            ctx: &HookContext,
3605            event: &InvalidToolCallContext,
3606        ) -> Option<InvalidToolCallAction> {
3607            RetryDefaultApiHook.on_invalid_tool_call(ctx, event).await
3608        }
3609    }
3610
3611    #[derive(Clone)]
3612    struct RecordingDeltaAndRetryInvalidToolHook {
3613        delta: RecordingToolCallDeltaHook,
3614    }
3615
3616    impl AgentHook for RecordingDeltaAndRetryInvalidToolHook {
3617        async fn on_tool_call_delta(
3618            &self,
3619            ctx: &HookContext,
3620            event: ToolCallDelta<'_>,
3621        ) -> ObservationAction {
3622            self.delta.on_tool_call_delta(ctx, event).await
3623        }
3624        async fn on_invalid_tool_call(
3625            &self,
3626            ctx: &HookContext,
3627            event: &InvalidToolCallContext,
3628        ) -> Option<InvalidToolCallAction> {
3629            RetryDefaultApiHook.on_invalid_tool_call(ctx, event).await
3630        }
3631    }
3632
3633    #[derive(Clone)]
3634    struct RecordingDeltaAndSkipInvalidToolHook {
3635        delta: RecordingToolCallDeltaHook,
3636    }
3637
3638    impl AgentHook for RecordingDeltaAndSkipInvalidToolHook {
3639        async fn on_tool_call_delta(
3640            &self,
3641            ctx: &HookContext,
3642            event: ToolCallDelta<'_>,
3643        ) -> ObservationAction {
3644            self.delta.on_tool_call_delta(ctx, event).await
3645        }
3646        async fn on_invalid_tool_call(
3647            &self,
3648            ctx: &HookContext,
3649            event: &InvalidToolCallContext,
3650        ) -> Option<InvalidToolCallAction> {
3651            SkipDefaultApiHook.on_invalid_tool_call(ctx, event).await
3652        }
3653    }
3654
3655    #[derive(Clone, Default)]
3656    struct TerminatingToolCallDeltaHook {
3657        deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
3658    }
3659
3660    impl TerminatingToolCallDeltaHook {
3661        fn observed(&self) -> Vec<RecordedToolCallDelta> {
3662            self.deltas
3663                .lock()
3664                .expect("tool call delta hook records mutex was poisoned")
3665                .clone()
3666        }
3667    }
3668
3669    impl AgentHook for TerminatingToolCallDeltaHook {
3670        async fn on_tool_call_delta(
3671            &self,
3672            _ctx: &HookContext,
3673            event: ToolCallDelta<'_>,
3674        ) -> ObservationAction {
3675            match event {
3676                ToolCallDelta {
3677                    internal_call_id,
3678                    tool_name,
3679                    delta,
3680                } => {
3681                    let record = (
3682                        internal_call_id.to_string(),
3683                        tool_name.map(str::to_string),
3684                        delta.to_string(),
3685                    );
3686                    self.deltas
3687                        .lock()
3688                        .expect("tool call delta hook records mutex was poisoned")
3689                        .push(record);
3690                    ObservationAction::stop("stop on tool call delta")
3691                }
3692                _ => ObservationAction::continue_run(),
3693            }
3694        }
3695    }
3696
3697    fn text_metadata(content: &[AssistantContent]) -> Option<&rig_core::message::AdditionalParams> {
3698        content.iter().find_map(|item| match item {
3699            AssistantContent::Text(text) => text.additional_params.as_ref(),
3700            _ => None,
3701        })
3702    }
3703
3704    #[tokio::test]
3705    async fn stream_prompt_continues_after_tool_call_turn() {
3706        let model = streaming_tool_then_text_model();
3707        let recorded = model.clone();
3708        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3709        let empty_history: &[Message] = &[];
3710
3711        let mut stream = agent
3712            .stream_prompt("do tool work")
3713            .history(empty_history)
3714            .max_turns(3)
3715            .await;
3716        let mut saw_tool_call = false;
3717        let mut saw_tool_result = false;
3718        let mut saw_final_response = false;
3719        let mut final_text = String::new();
3720        let mut final_response_text = None;
3721        let mut final_history = None;
3722
3723        while let Some(item) = stream.next().await {
3724            match item {
3725                Ok(MultiTurnStreamItem::StreamAssistantItem(
3726                    StreamedAssistantContent::ToolCall { .. },
3727                )) => {
3728                    saw_tool_call = true;
3729                }
3730                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3731                    ..
3732                })) => {
3733                    saw_tool_result = true;
3734                }
3735                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
3736                    text,
3737                ))) => {
3738                    final_text.push_str(&text.text);
3739                }
3740                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
3741                    saw_final_response = true;
3742                    final_response_text = Some(res.output().to_owned());
3743                    final_history = res.messages().map(|history| history.to_vec());
3744                    break;
3745                }
3746                Ok(_) => {}
3747                Err(err) => panic!("unexpected streaming error: {err:?}"),
3748            }
3749        }
3750
3751        assert!(saw_tool_call);
3752        assert!(saw_tool_result);
3753        assert!(saw_final_response);
3754        assert_eq!(final_text, "done");
3755        assert_eq!(final_response_text.as_deref(), Some("done"));
3756        let history = final_history.expect("expected final response history");
3757        assert!(history.iter().any(|message| matches!(
3758            message,
3759            Message::Assistant { content, .. }
3760                if content.iter().any(|item| matches!(
3761                    item,
3762                    AssistantContent::Text(text) if text.text == "done"
3763                ))
3764        )));
3765        let requests = recorded.requests();
3766        assert_eq!(requests.len(), 2);
3767        assert!(validate_follow_up_tool_history(&requests[1]).is_ok());
3768    }
3769
3770    /// `StreamingPromptRequest::tool_concurrency` reaches the runner: two
3771    /// barrier-synchronized tools in a streamed turn only finish if they run
3772    /// concurrently. At `tool_concurrency(2)` the stream completes; sequential
3773    /// execution would block on the first tool forever, so the timeout asserts
3774    /// the public builder actually enables concurrency on the streaming path.
3775    #[tokio::test]
3776    async fn streaming_prompt_request_tool_concurrency_runs_tools_concurrently() {
3777        let barrier = Arc::new(tokio::sync::Barrier::new(2));
3778        let model = MockCompletionModel::from_stream_turns([
3779            vec![
3780                MockStreamEvent::tool_call("b1", "barrier_tool", serde_json::json!({})),
3781                MockStreamEvent::tool_call("b2", "barrier_tool", serde_json::json!({})),
3782                MockStreamEvent::final_response_with_total_tokens(0),
3783            ],
3784            vec![
3785                MockStreamEvent::text("done"),
3786                MockStreamEvent::final_response_with_total_tokens(0),
3787            ],
3788        ]);
3789        let agent = AgentBuilder::new(model)
3790            .tool(MockBarrierTool::new(barrier))
3791            .build();
3792
3793        let drive = async {
3794            let mut stream = agent
3795                .stream_prompt("hit the barrier twice")
3796                .max_turns(3)
3797                .tool_concurrency(2)
3798                .await;
3799            while let Some(item) = stream.next().await {
3800                item.unwrap_or_else(|err| panic!("unexpected streaming error: {err:?}"));
3801            }
3802        };
3803
3804        tokio::time::timeout(Duration::from_secs(5), drive)
3805            .await
3806            .expect("streamed tools must run concurrently, not deadlock at the barrier");
3807    }
3808
3809    /// The streaming driver threads the per-call `ToolContext` to executed
3810    /// tools, exactly like the blocking path.
3811    #[tokio::test]
3812    async fn tool_context_reaches_tool_through_streaming_loop() {
3813        let model = MockCompletionModel::from_stream_turns([
3814            vec![
3815                MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
3816                    .with_call_id("call_1"),
3817                MockStreamEvent::final_response_with_total_tokens(4),
3818            ],
3819            vec![
3820                MockStreamEvent::text("done"),
3821                MockStreamEvent::final_response_with_total_tokens(6),
3822            ],
3823        ]);
3824        let probe = MockContextProbeTool::default();
3825        let agent = AgentBuilder::new(model).tool(probe.clone()).build();
3826        let empty_history: &[Message] = &[];
3827
3828        let mut tool_context = ToolContext::new();
3829        tool_context.insert(SessionId("xyz-789".to_string()));
3830
3831        let mut stream = agent
3832            .stream_prompt("do tool work")
3833            .tool_context(tool_context)
3834            .history(empty_history)
3835            .max_turns(3)
3836            .await;
3837
3838        while let Some(item) = stream.next().await {
3839            match item {
3840                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3841                Err(err) => panic!("unexpected streaming error: {err:?}"),
3842                Ok(_) => {}
3843            }
3844        }
3845
3846        assert_eq!(probe.observed().as_deref(), Some("session:xyz-789"));
3847    }
3848
3849    /// Streaming counterpart of the blocking empty-context default: when no
3850    /// [`ToolContext`] is supplied, the tool still receives a fresh empty
3851    /// context (observing `no-session`), not a stale value.
3852    #[tokio::test]
3853    async fn streaming_tool_runs_with_empty_context_when_none_supplied() {
3854        let model = MockCompletionModel::from_stream_turns([
3855            vec![
3856                MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
3857                    .with_call_id("call_1"),
3858                MockStreamEvent::final_response_with_total_tokens(4),
3859            ],
3860            vec![
3861                MockStreamEvent::text("done"),
3862                MockStreamEvent::final_response_with_total_tokens(6),
3863            ],
3864        ]);
3865        let probe = MockContextProbeTool::default();
3866        let agent = AgentBuilder::new(model).tool(probe.clone()).build();
3867        let empty_history: &[Message] = &[];
3868
3869        let mut stream = agent
3870            .stream_prompt("do tool work")
3871            .history(empty_history)
3872            .max_turns(3)
3873            .await;
3874
3875        while let Some(item) = stream.next().await {
3876            match item {
3877                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3878                Err(err) => panic!("unexpected streaming error: {err:?}"),
3879                Ok(_) => {}
3880            }
3881        }
3882
3883        assert_eq!(probe.observed().as_deref(), Some("no-session"));
3884    }
3885
3886    #[tokio::test]
3887    async fn unknown_tool_call_fails_before_streaming_second_request() {
3888        let model = MockCompletionModel::from_stream_turns([
3889            vec![
3890                MockStreamEvent::tool_call(
3891                    "tool_call_1",
3892                    "default_api",
3893                    serde_json::json!({"x": 1, "y": 2}),
3894                ),
3895                MockStreamEvent::final_response_with_total_tokens(4),
3896            ],
3897            vec![
3898                MockStreamEvent::text("should not be requested"),
3899                MockStreamEvent::final_response_with_total_tokens(6),
3900            ],
3901        ]);
3902        let recorded = model.clone();
3903        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3904
3905        let mut stream = agent
3906            .stream_prompt("use the tool")
3907            .add_hook(PanicOnUnknownToolHook)
3908            .max_turns(3)
3909            .await;
3910        let mut saw_tool_call = false;
3911        let mut error = None;
3912
3913        while let Some(item) = stream.next().await {
3914            match item {
3915                Ok(MultiTurnStreamItem::StreamAssistantItem(
3916                    StreamedAssistantContent::ToolCall { .. },
3917                )) => {
3918                    saw_tool_call = true;
3919                }
3920                Ok(_) => {}
3921                Err(err) => {
3922                    error = Some(err);
3923                    break;
3924                }
3925            }
3926        }
3927
3928        assert!(!saw_tool_call);
3929        let error = error.expect("unknown model-emitted tool should fail");
3930        match error {
3931            StreamingError::Prompt(err) => match *err {
3932                PromptError::UnknownToolCall {
3933                    tool_name,
3934                    available_tools,
3935                    allowed_tools,
3936                    chat_history,
3937                } => {
3938                    assert_eq!(tool_name, "default_api");
3939                    assert_eq!(available_tools, vec!["add".to_string()]);
3940                    assert_eq!(allowed_tools, vec!["add".to_string()]);
3941                    assert!(history_contains_tool_call(&chat_history, "default_api"));
3942                }
3943                other => panic!("expected UnknownToolCall, got {other:?}"),
3944            },
3945            other => panic!("expected prompt streaming error, got {other:?}"),
3946        }
3947        assert_eq!(recorded.request_count(), 1);
3948    }
3949
3950    #[tokio::test]
3951    async fn invalid_tool_call_hook_can_repair_streaming_tool_name() {
3952        let model = MockCompletionModel::from_stream_turns([
3953            vec![
3954                MockStreamEvent::tool_call(
3955                    "tool_call_1",
3956                    "default_api",
3957                    serde_json::json!({"x": 2, "y": 3}),
3958                ),
3959                MockStreamEvent::final_response_with_total_tokens(4),
3960            ],
3961            vec![
3962                MockStreamEvent::text("done"),
3963                MockStreamEvent::final_response_with_total_tokens(6),
3964            ],
3965        ]);
3966        let recorded = model.clone();
3967        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3968
3969        let mut stream = agent
3970            .stream_prompt("use the tool")
3971            .add_hook(RepairDefaultApiHook)
3972            .max_turns(3)
3973            .history(Vec::<Message>::new())
3974            .await;
3975        let mut saw_repaired_tool_call = false;
3976        let mut saw_tool_result = false;
3977        let mut final_response_text = None;
3978
3979        while let Some(item) = stream.next().await {
3980            match item {
3981                Ok(MultiTurnStreamItem::StreamAssistantItem(
3982                    StreamedAssistantContent::ToolCall { tool_call, .. },
3983                )) => {
3984                    assert_eq!(tool_call.function.name, "add");
3985                    saw_repaired_tool_call = true;
3986                }
3987                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3988                    tool_result,
3989                    ..
3990                })) => {
3991                    assert!(tool_result.content.iter().any(|content| {
3992                        matches!(
3993                            content,
3994                            ToolResultContent::Json { value }
3995                                if value == &serde_json::json!(5)
3996                        )
3997                    }));
3998                    saw_tool_result = true;
3999                }
4000                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4001                    final_response_text = Some(response.output().to_string());
4002                    break;
4003                }
4004                Ok(_) => {}
4005                Err(err) => panic!("unexpected streaming error: {err:?}"),
4006            }
4007        }
4008
4009        assert!(saw_repaired_tool_call);
4010        assert!(saw_tool_result);
4011        assert_eq!(final_response_text.as_deref(), Some("done"));
4012        assert_eq!(recorded.request_count(), 2);
4013    }
4014
4015    #[tokio::test]
4016    async fn invalid_tool_call_context_uses_completed_streaming_tool_call_provider_id() {
4017        let invalid_hook = RecordingInvalidToolCallHook::default();
4018        let model = MockCompletionModel::from_stream_turns([
4019            vec![
4020                MockStreamEvent::tool_call(
4021                    "tool_call_1",
4022                    "default_api",
4023                    serde_json::json!({"x": 2, "y": 3}),
4024                )
4025                .with_call_id("provider_call_1"),
4026                MockStreamEvent::final_response_with_total_tokens(4),
4027            ],
4028            vec![
4029                MockStreamEvent::text("should not be requested"),
4030                MockStreamEvent::final_response_with_total_tokens(6),
4031            ],
4032        ]);
4033        let recorded = model.clone();
4034        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4035
4036        let mut stream = agent
4037            .stream_prompt("use the tool")
4038            .add_hook(invalid_hook.clone())
4039            .max_turns(3)
4040            .await;
4041        let mut error = None;
4042
4043        while let Some(item) = stream.next().await {
4044            if let Err(err) = item {
4045                error = Some(err);
4046                break;
4047            }
4048        }
4049
4050        assert!(error.is_some(), "invalid tool should fail");
4051        assert_eq!(recorded.request_count(), 1);
4052        let contexts = invalid_hook.observed();
4053        assert_eq!(contexts.len(), 1);
4054        let context = &contexts[0];
4055        assert_eq!(context.tool_name, "default_api");
4056        // The call COMPLETED with provider identifiers: the correlator
4057        // ("provider_call_1") drives rig's durable id, which is what the
4058        // context reports; the wire's item id travels on `provider`.
4059        assert_eq!(context.tool_call_id.as_deref(), Some("provider_call_1"));
4060        assert!(context.internal_call_id.is_some());
4061        assert!(context.is_streaming);
4062    }
4063
4064    #[tokio::test]
4065    async fn invalid_tool_call_hook_skip_emits_streaming_tool_result() {
4066        let add_calls = Arc::new(AtomicU32::new(0));
4067        let model = MockCompletionModel::from_stream_turns([
4068            vec![
4069                MockStreamEvent::tool_call(
4070                    "tool_call_1",
4071                    "default_api",
4072                    serde_json::json!({"x": 2, "y": 3}),
4073                )
4074                .with_call_id("call_1"),
4075                MockStreamEvent::final_response_with_total_tokens(4),
4076            ],
4077            vec![
4078                MockStreamEvent::text("continued"),
4079                MockStreamEvent::final_response_with_total_tokens(6),
4080            ],
4081        ]);
4082        let recorded = model.clone();
4083        let agent = AgentBuilder::new(model)
4084            .tool(CountingAddTool {
4085                calls: add_calls.clone(),
4086            })
4087            .build();
4088
4089        let mut stream = agent
4090            .stream_prompt("use the tool")
4091            .add_hook(SkipDefaultApiHook)
4092            .max_turns(3)
4093            .history(Vec::<Message>::new())
4094            .await;
4095        let mut skipped_tool_result = None;
4096        let mut final_response_text = None;
4097
4098        while let Some(item) = stream.next().await {
4099            match item {
4100                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4101                    tool_result,
4102                    internal_call_id,
4103                })) => {
4104                    assert!(!internal_call_id.is_empty());
4105                    skipped_tool_result = Some(tool_result);
4106                }
4107                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4108                    final_response_text = Some(response.output().to_string());
4109                    break;
4110                }
4111                Ok(_) => {}
4112                Err(err) => panic!("unexpected streaming error: {err:?}"),
4113            }
4114        }
4115
4116        let skipped_tool_result =
4117            skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
4118        // The correlator ("call_1") is the durable id; the wire's item id
4119        // ("tool_call_1") travels on `provider`.
4120        assert_eq!(skipped_tool_result.call, "call_1");
4121        assert!(
4122            skipped_tool_result
4123                .provider
4124                .as_ref()
4125                .is_some_and(|provider| {
4126                    provider.call_id == "call_1"
4127                        && provider.item_id.as_deref() == Some("tool_call_1")
4128                })
4129        );
4130        assert!(skipped_tool_result.content.iter().any(|content| matches!(
4131            content,
4132            ToolResultContent::Text(text) if text.text == "default_api was skipped"
4133        )));
4134        assert_eq!(final_response_text.as_deref(), Some("continued"));
4135        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4136
4137        let requests = recorded.requests();
4138        assert_eq!(requests.len(), 2);
4139        let follow_up_history = requests[1].chat_history.clone();
4140        assert!(matches!(
4141            follow_up_history.get(2),
4142            Some(Message::User { content })
4143                if content.iter().any(|item| matches!(
4144                    item,
4145                    UserContent::ToolResult(result)
4146                        if result.call == "call_1"
4147                            && result.content.iter().any(|content| matches!(
4148                                content,
4149                                ToolResultContent::Text(text)
4150                                    if text.text == "default_api was skipped"
4151                            ))
4152                ))
4153        ));
4154    }
4155
4156    #[tokio::test]
4157    async fn invalid_tool_call_hook_retries_mixed_streaming_turn_without_executing_valid_call() {
4158        let add_calls = Arc::new(AtomicU32::new(0));
4159        let model = MockCompletionModel::from_stream_turns([
4160            vec![
4161                MockStreamEvent::text("checking "),
4162                MockStreamEvent::tool_call(
4163                    "tool_call_1",
4164                    "add",
4165                    serde_json::json!({"x": 2, "y": 3}),
4166                )
4167                .with_call_id("call_1"),
4168                MockStreamEvent::tool_call(
4169                    "tool_call_2",
4170                    "default_api",
4171                    serde_json::json!({"x": 4, "y": 5}),
4172                )
4173                .with_call_id("call_2"),
4174                MockStreamEvent::final_response_with_total_tokens(4),
4175            ],
4176            vec![
4177                MockStreamEvent::text("retried"),
4178                MockStreamEvent::final_response_with_total_tokens(6),
4179            ],
4180        ]);
4181        let recorded = model.clone();
4182        let agent = AgentBuilder::new(model)
4183            .tool(CountingAddTool {
4184                calls: add_calls.clone(),
4185            })
4186            .build();
4187
4188        let mut stream = agent
4189            .stream_prompt("use the tool")
4190            .add_hook(RetryDefaultApiHook)
4191            .max_turns(3)
4192            .history(Vec::<Message>::new())
4193            .max_invalid_tool_call_retries(1)
4194            .await;
4195        let mut completion_call_events = Vec::new();
4196        let mut final_response_text = None;
4197        let mut final_response_usage = Usage::new();
4198        let mut final_completion_calls = Vec::new();
4199
4200        while let Some(item) = stream.next().await {
4201            match item {
4202                Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
4203                    completion_call_events.push(completion_call);
4204                }
4205                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4206                    final_response_text = Some(response.output().to_string());
4207                    final_response_usage = response.usage();
4208                    final_completion_calls = response.completion_calls().to_vec();
4209                    break;
4210                }
4211                Ok(_) => {}
4212                Err(err) => panic!("unexpected streaming error: {err:?}"),
4213            }
4214        }
4215
4216        assert_eq!(final_response_text.as_deref(), Some("retried"));
4217        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4218        let mut first_usage = Usage::new();
4219        first_usage.total_tokens = 4;
4220        let mut second_usage = Usage::new();
4221        second_usage.total_tokens = 6;
4222        let expected_completion_calls = vec![
4223            streamed_call(0, first_usage),
4224            streamed_call(1, second_usage),
4225        ];
4226        assert_eq!(completion_call_events, expected_completion_calls);
4227        assert_eq!(final_completion_calls, expected_completion_calls);
4228        assert_eq!(final_response_usage.total_tokens, 10);
4229
4230        let requests = recorded.requests();
4231        assert_eq!(requests.len(), 2);
4232        let retry_history = requests[1].chat_history.clone();
4233        assert_eq!(retry_history.len(), 3);
4234        assert!(matches!(
4235            retry_history.get(1),
4236            Some(Message::Assistant { content, .. })
4237                if content.iter().any(|item| matches!(
4238                    item,
4239                    AssistantContent::Text(text) if text.text == "checking "
4240                ))
4241                    && content.iter().any(|item| matches!(
4242                        item,
4243                        AssistantContent::ToolCall(tool_call)
4244                            if tool_call.id == "call_1"
4245                                && tool_call.function.name == "add"
4246                    ))
4247                    && content.iter().any(|item| matches!(
4248                        item,
4249                        AssistantContent::ToolCall(tool_call)
4250                            if tool_call.id == "call_2"
4251                                && tool_call.function.name == "default_api"
4252                    ))
4253        ));
4254        assert!(matches!(
4255            retry_history.get(2),
4256            Some(Message::User { content })
4257                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4258                    && content.iter().any(|item| matches!(
4259                        item,
4260                        UserContent::ToolResult(result)
4261                            if result.call == "call_1"
4262                                && result.content.iter().any(|content| matches!(
4263                                    content,
4264                                    ToolResultContent::Text(text)
4265                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4266                                ))
4267                    ))
4268                    && content.iter().any(|item| matches!(
4269                        item,
4270                        UserContent::ToolResult(result)
4271                            if result.call == "call_2"
4272                                && result.content.iter().any(|content| matches!(
4273                                    content,
4274                                    ToolResultContent::Text(text)
4275                                        if text.text == "Use the add tool instead"
4276                                ))
4277                    ))
4278        ));
4279        assert_retry_transcript_ids_pair(
4280            retry_history.get(1).expect("assistant tool-call turn"),
4281            retry_history.get(2).expect("retry-result turn"),
4282        );
4283    }
4284
4285    #[tokio::test]
4286    async fn invalid_tool_call_hook_skips_mixed_streaming_turn_without_executing_valid_call() {
4287        let add_calls = Arc::new(AtomicU32::new(0));
4288        let model = MockCompletionModel::from_stream_turns([
4289            vec![
4290                MockStreamEvent::text("checking "),
4291                MockStreamEvent::tool_call(
4292                    "tool_call_1",
4293                    "add",
4294                    serde_json::json!({"x": 2, "y": 3}),
4295                )
4296                .with_call_id("call_1"),
4297                MockStreamEvent::tool_call(
4298                    "tool_call_2",
4299                    "default_api",
4300                    serde_json::json!({"x": 4, "y": 5}),
4301                )
4302                .with_call_id("call_2"),
4303                MockStreamEvent::final_response_with_total_tokens(4),
4304            ],
4305            vec![
4306                MockStreamEvent::text("continued"),
4307                MockStreamEvent::final_response_with_total_tokens(6),
4308            ],
4309        ]);
4310        let recorded = model.clone();
4311        let agent = AgentBuilder::new(model)
4312            .tool(CountingAddTool {
4313                calls: add_calls.clone(),
4314            })
4315            .build();
4316
4317        let mut stream = agent
4318            .stream_prompt("use the tool")
4319            .add_hook(SkipDefaultApiHook)
4320            .max_turns(3)
4321            .history(Vec::<Message>::new())
4322            .await;
4323        let mut skipped_tool_result = None;
4324        let mut final_response_text = None;
4325
4326        while let Some(item) = stream.next().await {
4327            match item {
4328                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4329                    tool_result,
4330                    ..
4331                })) => {
4332                    skipped_tool_result = Some(tool_result);
4333                }
4334                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4335                    final_response_text = Some(response.output().to_string());
4336                    break;
4337                }
4338                Ok(_) => {}
4339                Err(err) => panic!("unexpected streaming error: {err:?}"),
4340            }
4341        }
4342
4343        let skipped_tool_result =
4344            skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
4345        // The correlator ("call_2") is the durable id; the wire's item id
4346        // ("tool_call_2") travels on `provider`.
4347        assert_eq!(skipped_tool_result.call, "call_2");
4348        assert!(
4349            skipped_tool_result
4350                .provider
4351                .as_ref()
4352                .is_some_and(|provider| {
4353                    provider.call_id == "call_2"
4354                        && provider.item_id.as_deref() == Some("tool_call_2")
4355                })
4356        );
4357        assert_eq!(final_response_text.as_deref(), Some("continued"));
4358        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4359
4360        let requests = recorded.requests();
4361        assert_eq!(requests.len(), 2);
4362        let follow_up_history = requests[1].chat_history.clone();
4363        assert_eq!(follow_up_history.len(), 3);
4364        assert!(matches!(
4365            follow_up_history.get(1),
4366            Some(Message::Assistant { content, .. })
4367                if content.iter().any(|item| matches!(
4368                    item,
4369                    AssistantContent::Text(text) if text.text == "checking "
4370                ))
4371                    && content.iter().any(|item| matches!(
4372                        item,
4373                        AssistantContent::ToolCall(tool_call)
4374                            if tool_call.id == "call_1"
4375                                && tool_call.function.name == "add"
4376                    ))
4377                    && content.iter().any(|item| matches!(
4378                        item,
4379                        AssistantContent::ToolCall(tool_call)
4380                            if tool_call.id == "call_2"
4381                                && tool_call.function.name == "default_api"
4382                    ))
4383        ));
4384        assert!(matches!(
4385            follow_up_history.get(2),
4386            Some(Message::User { content })
4387                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4388                    && content.iter().any(|item| matches!(
4389                        item,
4390                        UserContent::ToolResult(result)
4391                            if result.call == "call_1"
4392                                && result.provider.as_ref().is_some_and(
4393                                    |provider| provider.call_id == "call_1"
4394                                )
4395                                && result.content.iter().any(|content| matches!(
4396                                    content,
4397                                    ToolResultContent::Text(text)
4398                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4399                                ))
4400                    ))
4401                    && content.iter().any(|item| matches!(
4402                        item,
4403                        UserContent::ToolResult(result)
4404                            if result.call == "call_2"
4405                                && result.provider.as_ref().is_some_and(
4406                                    |provider| provider.call_id == "call_2"
4407                                )
4408                                && result.content.iter().any(|content| matches!(
4409                                    content,
4410                                    ToolResultContent::Text(text)
4411                                        if text.text == "default_api was skipped"
4412                                ))
4413            ))
4414        ));
4415        assert_retry_transcript_ids_pair(
4416            follow_up_history.get(1).expect("assistant tool-call turn"),
4417            follow_up_history.get(2).expect("skip-result turn"),
4418        );
4419    }
4420
4421    #[tokio::test]
4422    async fn invalid_completed_tool_call_skip_preserves_streaming_reasoning_history() {
4423        let model = MockCompletionModel::from_stream_turns([
4424            vec![
4425                MockStreamEvent::text("checking "),
4426                MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
4427                MockStreamEvent::tool_call(
4428                    "tool_call_1",
4429                    "default_api",
4430                    serde_json::json!({"x": 2, "y": 3}),
4431                ),
4432                MockStreamEvent::final_response_with_total_tokens(4),
4433            ],
4434            vec![
4435                MockStreamEvent::text("continued"),
4436                MockStreamEvent::final_response_with_total_tokens(6),
4437            ],
4438        ]);
4439        let recorded = model.clone();
4440        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4441
4442        let mut stream = agent
4443            .stream_prompt("use the tool")
4444            .add_hook(SkipDefaultApiHook)
4445            .max_turns(3)
4446            .history(Vec::<Message>::new())
4447            .await;
4448
4449        while let Some(item) = stream.next().await {
4450            match item {
4451                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4452                Ok(_) => {}
4453                Err(err) => panic!("unexpected streaming error: {err:?}"),
4454            }
4455        }
4456
4457        let requests = recorded.requests();
4458        assert_eq!(requests.len(), 2);
4459        let follow_up_history = requests[1].chat_history.clone();
4460        assert!(history_contains_text(&follow_up_history, "checking "));
4461        assert!(assistant_reasoning_precedes_tool_call(
4462            &follow_up_history,
4463            "reasoned step",
4464            "default_api"
4465        ));
4466        assert!(
4467            assistant_reasoning_precedes_text_and_tool_call(
4468                &follow_up_history,
4469                "reasoned step",
4470                "checking ",
4471                "default_api"
4472            ),
4473            "{follow_up_history:?}"
4474        );
4475    }
4476
4477    #[tokio::test]
4478    async fn invalid_name_delta_retry_preserves_streaming_reasoning_history() {
4479        let model = MockCompletionModel::from_stream_turns([
4480            vec![
4481                MockStreamEvent::reasoning_delta_with_id("rs_1", "delta reason"),
4482                MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4483                MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4484                MockStreamEvent::final_response_with_total_tokens(4),
4485            ],
4486            vec![
4487                MockStreamEvent::text("retried"),
4488                MockStreamEvent::final_response_with_total_tokens(6),
4489            ],
4490        ]);
4491        let recorded = model.clone();
4492        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4493
4494        let mut stream = agent
4495            .stream_prompt("use the tool")
4496            .add_hook(RetryDefaultApiHook)
4497            .max_turns(3)
4498            .history(Vec::<Message>::new())
4499            .max_invalid_tool_call_retries(1)
4500            .await;
4501
4502        while let Some(item) = stream.next().await {
4503            match item {
4504                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4505                Ok(_) => {}
4506                Err(err) => panic!("unexpected streaming error: {err:?}"),
4507            }
4508        }
4509
4510        let requests = recorded.requests();
4511        assert_eq!(requests.len(), 2);
4512        let retry_history = requests[1].chat_history.clone();
4513        assert!(assistant_reasoning_precedes_tool_call(
4514            &retry_history,
4515            "delta reason",
4516            "default_api"
4517        ));
4518    }
4519
4520    #[tokio::test]
4521    async fn invalid_tool_call_hook_skip_resets_streaming_text_delta_state() {
4522        let text_hook = RecordingTextDeltaHook::default();
4523        let model = MockCompletionModel::from_stream_turns([
4524            vec![
4525                MockStreamEvent::text("stale "),
4526                MockStreamEvent::tool_call(
4527                    "tool_call_1",
4528                    "default_api",
4529                    serde_json::json!({"x": 2, "y": 3}),
4530                ),
4531                MockStreamEvent::final_response_with_total_tokens(4),
4532            ],
4533            vec![
4534                MockStreamEvent::text("fresh"),
4535                MockStreamEvent::final_response_with_total_tokens(6),
4536            ],
4537        ]);
4538        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4539
4540        let mut stream = agent
4541            .stream_prompt("use the tool")
4542            .add_hook(RecordingTextAndSkipInvalidToolHook {
4543                text: text_hook.clone(),
4544            })
4545            .max_turns(3)
4546            .history(Vec::<Message>::new())
4547            .await;
4548
4549        while let Some(item) = stream.next().await {
4550            match item {
4551                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4552                Ok(_) => {}
4553                Err(err) => panic!("unexpected streaming error: {err:?}"),
4554            }
4555        }
4556
4557        assert_eq!(
4558            text_hook.observed(),
4559            vec![
4560                ("stale ".to_string(), "stale ".to_string()),
4561                ("fresh".to_string(), "fresh".to_string()),
4562            ]
4563        );
4564    }
4565
4566    #[tokio::test]
4567    async fn invalid_tool_call_delta_retry_uses_structured_tool_feedback() {
4568        let delta_hook = RecordingToolCallDeltaHook::default();
4569        let add_calls = Arc::new(AtomicU32::new(0));
4570        let model = MockCompletionModel::from_stream_turns([
4571            vec![
4572                MockStreamEvent::text("checking "),
4573                MockStreamEvent::reasoning_delta_with_id("rs_1", "diagnostic reason"),
4574                MockStreamEvent::tool_call(
4575                    "tool_call_0",
4576                    "add",
4577                    serde_json::json!({"x": 1, "y": 2}),
4578                )
4579                .with_call_id("call_0"),
4580                MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4581                MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4582                MockStreamEvent::final_response_with_total_tokens(4),
4583            ],
4584            vec![
4585                MockStreamEvent::text("retried"),
4586                MockStreamEvent::final_response_with_total_tokens(6),
4587            ],
4588        ]);
4589        let recorded = model.clone();
4590        let agent = AgentBuilder::new(model)
4591            .tool(CountingAddTool {
4592                calls: add_calls.clone(),
4593            })
4594            .build();
4595
4596        let mut stream = agent
4597            .stream_prompt("use the tool")
4598            .add_hook(RecordingDeltaAndRetryInvalidToolHook {
4599                delta: delta_hook.clone(),
4600            })
4601            .max_turns(3)
4602            .history(Vec::<Message>::new())
4603            .max_invalid_tool_call_retries(1)
4604            .await;
4605        let mut completion_call_events = Vec::new();
4606        let mut final_response_text = None;
4607        let mut final_response_usage = Usage::new();
4608        let mut final_completion_calls = Vec::new();
4609
4610        while let Some(item) = stream.next().await {
4611            match item {
4612                Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
4613                    completion_call_events.push(completion_call);
4614                }
4615                Ok(MultiTurnStreamItem::StreamAssistantItem(
4616                    StreamedAssistantContent::ToolCallDelta { .. },
4617                )) => panic!("invalid tool-call delta should not be emitted"),
4618                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4619                    final_response_text = Some(response.output().to_string());
4620                    final_response_usage = response.usage();
4621                    final_completion_calls = response.completion_calls().to_vec();
4622                    break;
4623                }
4624                Ok(_) => {}
4625                Err(err) => panic!("unexpected streaming error: {err:?}"),
4626            }
4627        }
4628
4629        assert_eq!(final_response_text.as_deref(), Some("retried"));
4630        assert!(delta_hook.observed().is_empty());
4631        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4632        let mut first_usage = Usage::new();
4633        first_usage.total_tokens = 4;
4634        let mut second_usage = Usage::new();
4635        second_usage.total_tokens = 6;
4636        let expected_completion_calls = vec![
4637            streamed_call(0, first_usage),
4638            streamed_call(1, second_usage),
4639        ];
4640        assert_eq!(completion_call_events, expected_completion_calls);
4641        assert_eq!(final_completion_calls, expected_completion_calls);
4642        assert_eq!(final_response_usage.total_tokens, 10);
4643
4644        let requests = recorded.requests();
4645        assert_eq!(requests.len(), 2);
4646        let retry_history = requests[1].chat_history.clone();
4647        assert!(matches!(
4648            retry_history.get(1),
4649            Some(Message::Assistant { content, .. })
4650                if content.iter().any(|item| matches!(
4651                    item,
4652                    AssistantContent::Text(text) if text.text == "checking "
4653                ))
4654                    && content.iter().any(|item| matches!(
4655                        item,
4656                        AssistantContent::ToolCall(tool_call)
4657                            if tool_call.id == "call_0"
4658                                && tool_call.function.name == "add"
4659                    ))
4660                    && content.iter().any(|item| matches!(
4661                    item,
4662                    // An invalid NAME DELTA never completed, so no provider
4663                    // id exists — the diagnostic call mints its correlation
4664                    // handle at the boundary (wire schemas require a
4665                    // non-empty tool_call_id; stream keys never surface).
4666                    AssistantContent::ToolCall(tool_call)
4667                        if !tool_call.id.is_empty()
4668                            && tool_call.provider.is_none()
4669                            && tool_call.function.name == "default_api"
4670                            && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
4671                ))
4672        ));
4673        assert!(matches!(
4674            retry_history.get(2),
4675            Some(Message::User { content })
4676                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4677                    && content.iter().any(|item| matches!(
4678                        item,
4679                        UserContent::ToolResult(result)
4680                            if result.call == "call_0"
4681                                && result.provider.as_ref().is_some_and(
4682                                    |provider| provider.call_id == "call_0"
4683                                )
4684                                && result.content.iter().any(|content| matches!(
4685                                    content,
4686                                    ToolResultContent::Text(text)
4687                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4688                                ))
4689                    ))
4690                    && content.iter().any(|item| matches!(
4691                    item,
4692                    UserContent::ToolResult(result)
4693                        if !result.call.is_empty()
4694                            && result.name == "default_api"
4695                            && result.content.iter().any(|content| matches!(
4696                                content,
4697                                ToolResultContent::Text(text)
4698                                    if text.text == "Use the add tool instead"
4699                            ))
4700                ))
4701        ));
4702        assert_retry_transcript_ids_pair(
4703            retry_history.get(1).expect("assistant tool-call turn"),
4704            retry_history.get(2).expect("retry-result turn"),
4705        );
4706    }
4707
4708    #[tokio::test]
4709    async fn invalid_tool_call_delta_context_includes_same_turn_history_and_tool_call_id() {
4710        let invalid_hook = RecordingInvalidToolCallHook::default();
4711        let model = MockCompletionModel::from_stream_turns([
4712            vec![
4713                MockStreamEvent::text("checking "),
4714                MockStreamEvent::reasoning_delta_with_id("rs_1", "diagnostic reason"),
4715                MockStreamEvent::tool_call(
4716                    "tool_call_0",
4717                    "add",
4718                    serde_json::json!({"x": 1, "y": 2}),
4719                )
4720                .with_call_id("call_0"),
4721                MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4722                MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4723                MockStreamEvent::final_response_with_total_tokens(4),
4724            ],
4725            vec![
4726                MockStreamEvent::text("should not be requested"),
4727                MockStreamEvent::final_response_with_total_tokens(6),
4728            ],
4729        ]);
4730        let recorded = model.clone();
4731        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4732
4733        let mut stream = agent
4734            .stream_prompt("use the tool")
4735            .add_hook(invalid_hook.clone())
4736            .max_turns(3)
4737            .await;
4738        let mut error = None;
4739
4740        while let Some(item) = stream.next().await {
4741            if let Err(err) = item {
4742                error = Some(err);
4743                break;
4744            }
4745        }
4746
4747        assert!(error.is_some(), "invalid name delta should fail");
4748        assert_eq!(recorded.request_count(), 1);
4749        let contexts = invalid_hook.observed();
4750        assert_eq!(contexts.len(), 1);
4751        let context = &contexts[0];
4752        assert_eq!(context.tool_name, "default_api");
4753        // The invalid name delta never completed, so no PROVIDER id exists —
4754        // the durable id the context reports is rig's minted handle (always
4755        // present and non-empty, never an empty sentinel), and correlation
4756        // with stream events is by internal_call_id.
4757        assert!(
4758            context
4759                .tool_call_id
4760                .as_deref()
4761                .is_some_and(|id| !id.is_empty()),
4762            "an unfinished call still carries a non-empty minted durable id, got {:?}",
4763            context.tool_call_id
4764        );
4765        assert!(
4766            context
4767                .internal_call_id
4768                .as_deref()
4769                .is_some_and(|id| !id.is_empty()),
4770            "internal call id is minted by the shared accumulator"
4771        );
4772        assert!(context.is_streaming);
4773        assert!(history_contains_text(&context.chat_history, "checking "));
4774        assert!(
4775            assistant_reasoning_precedes_tool_call(
4776                &context.chat_history,
4777                "diagnostic reason",
4778                "add"
4779            ),
4780            "{:?}",
4781            context.chat_history
4782        );
4783        assert!(history_contains_tool_call(&context.chat_history, "add"));
4784        assert!(history_contains_tool_call(
4785            &context.chat_history,
4786            "default_api"
4787        ));
4788    }
4789
4790    #[tokio::test]
4791    async fn invalid_tool_call_delta_retry_resets_streaming_text_delta_state() {
4792        let text_hook = RecordingTextDeltaHook::default();
4793        let model = MockCompletionModel::from_stream_turns([
4794            vec![
4795                MockStreamEvent::text("stale "),
4796                MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4797                MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4798                MockStreamEvent::final_response_with_total_tokens(4),
4799            ],
4800            vec![
4801                MockStreamEvent::text("fresh"),
4802                MockStreamEvent::final_response_with_total_tokens(6),
4803            ],
4804        ]);
4805        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4806
4807        let mut stream = agent
4808            .stream_prompt("use the tool")
4809            .add_hook(RecordingTextAndRetryInvalidToolHook {
4810                text: text_hook.clone(),
4811            })
4812            .max_turns(3)
4813            .history(Vec::<Message>::new())
4814            .max_invalid_tool_call_retries(1)
4815            .await;
4816
4817        while let Some(item) = stream.next().await {
4818            match item {
4819                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4820                Ok(_) => {}
4821                Err(err) => panic!("unexpected streaming error: {err:?}"),
4822            }
4823        }
4824
4825        assert_eq!(
4826            text_hook.observed(),
4827            vec![
4828                ("stale ".to_string(), "stale ".to_string()),
4829                ("fresh".to_string(), "fresh".to_string()),
4830            ]
4831        );
4832    }
4833
4834    #[tokio::test]
4835    async fn invalid_tool_call_delta_skip_uses_structured_tool_feedback() {
4836        let delta_hook = RecordingToolCallDeltaHook::default();
4837        let add_calls = Arc::new(AtomicU32::new(0));
4838        let model = MockCompletionModel::from_stream_turns([
4839            vec![
4840                MockStreamEvent::text("checking "),
4841                MockStreamEvent::tool_call(
4842                    "tool_call_0",
4843                    "add",
4844                    serde_json::json!({"x": 1, "y": 2}),
4845                )
4846                .with_call_id("call_0"),
4847                MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4848                MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4849                MockStreamEvent::final_response_with_total_tokens(4),
4850            ],
4851            vec![
4852                MockStreamEvent::text("continued"),
4853                MockStreamEvent::final_response_with_total_tokens(6),
4854            ],
4855        ]);
4856        let recorded = model.clone();
4857        let agent = AgentBuilder::new(model)
4858            .tool(CountingAddTool {
4859                calls: add_calls.clone(),
4860            })
4861            .build();
4862
4863        let mut stream = agent
4864            .stream_prompt("use the tool")
4865            .add_hook(RecordingDeltaAndSkipInvalidToolHook {
4866                delta: delta_hook.clone(),
4867            })
4868            .max_turns(3)
4869            .history(Vec::<Message>::new())
4870            .await;
4871        let mut skipped_tool_result = None;
4872        let mut final_response_text = None;
4873
4874        while let Some(item) = stream.next().await {
4875            match item {
4876                Ok(MultiTurnStreamItem::StreamAssistantItem(
4877                    StreamedAssistantContent::ToolCallDelta { .. },
4878                )) => panic!("invalid tool-call delta should not be emitted"),
4879                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4880                    tool_result,
4881                    internal_call_id,
4882                })) => {
4883                    assert!(
4884                        !internal_call_id.is_empty(),
4885                        "internal call id is minted by the shared accumulator"
4886                    );
4887                    skipped_tool_result = Some(tool_result);
4888                }
4889                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4890                    final_response_text = Some(response.output().to_string());
4891                    break;
4892                }
4893                Ok(_) => {}
4894                Err(err) => panic!("unexpected streaming error: {err:?}"),
4895            }
4896        }
4897
4898        let skipped_tool_result =
4899            skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
4900        // The invalid name delta never completed, so no provider id exists:
4901        // `provider` faithfully records that absence, while the diagnostic
4902        // call mints rig's correlation handle at the boundary — the synthetic
4903        // result carries that non-empty minted id, never an empty sentinel.
4904        assert!(!skipped_tool_result.call.is_empty());
4905        assert_eq!(skipped_tool_result.name, "default_api");
4906        assert!(skipped_tool_result.provider.is_none());
4907        assert!(skipped_tool_result.content.iter().any(|content| matches!(
4908            content,
4909            ToolResultContent::Text(text) if text.text == "default_api was skipped"
4910        )));
4911        assert_eq!(final_response_text.as_deref(), Some("continued"));
4912        assert!(delta_hook.observed().is_empty());
4913        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4914
4915        let requests = recorded.requests();
4916        assert_eq!(requests.len(), 2);
4917        let follow_up_history = requests[1].chat_history.clone();
4918        assert!(matches!(
4919            follow_up_history.get(1),
4920            Some(Message::Assistant { content, .. })
4921                if content.iter().any(|item| matches!(
4922                    item,
4923                    AssistantContent::Text(text) if text.text == "checking "
4924                ))
4925                    && content.iter().any(|item| matches!(
4926                        item,
4927                        AssistantContent::ToolCall(tool_call)
4928                            if tool_call.id == "call_0"
4929                                && tool_call.function.name == "add"
4930                    ))
4931                    && content.iter().any(|item| matches!(
4932                    item,
4933                    // An invalid NAME DELTA never completed, so no provider
4934                    // id exists — the diagnostic call mints its correlation
4935                    // handle at the boundary (wire schemas require a
4936                    // non-empty tool_call_id; stream keys never surface).
4937                    AssistantContent::ToolCall(tool_call)
4938                        if !tool_call.id.is_empty()
4939                            && tool_call.provider.is_none()
4940                            && tool_call.function.name == "default_api"
4941                            && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
4942                ))
4943        ));
4944        assert!(matches!(
4945            follow_up_history.get(2),
4946            Some(Message::User { content })
4947                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4948                    && content.iter().any(|item| matches!(
4949                        item,
4950                        UserContent::ToolResult(result)
4951                            if result.call == "call_0"
4952                                && result.provider.as_ref().is_some_and(
4953                                    |provider| provider.call_id == "call_0"
4954                                )
4955                                && result.content.iter().any(|content| matches!(
4956                                    content,
4957                                    ToolResultContent::Text(text)
4958                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4959                                ))
4960                    ))
4961                    && content.iter().any(|item| matches!(
4962                    item,
4963                    UserContent::ToolResult(result)
4964                        if !result.call.is_empty()
4965                            && result.name == "default_api"
4966                            && result.content.iter().any(|content| matches!(
4967                                content,
4968                                ToolResultContent::Text(text)
4969                                    if text.text == "default_api was skipped"
4970                            ))
4971                ))
4972        ));
4973        assert_retry_transcript_ids_pair(
4974            follow_up_history.get(1).expect("assistant tool-call turn"),
4975            follow_up_history.get(2).expect("skip-result turn"),
4976        );
4977    }
4978
4979    #[tokio::test]
4980    async fn streaming_retry_budget_exhaustion_history_contains_invalid_tool_call() {
4981        let model = MockCompletionModel::from_stream_turns([
4982            vec![
4983                MockStreamEvent::tool_call(
4984                    "tool_call_1",
4985                    "default_api",
4986                    serde_json::json!({"x": 1, "y": 2}),
4987                ),
4988                MockStreamEvent::final_response_with_total_tokens(4),
4989            ],
4990            vec![
4991                MockStreamEvent::text("should not be requested"),
4992                MockStreamEvent::final_response_with_total_tokens(6),
4993            ],
4994        ]);
4995        let recorded = model.clone();
4996        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4997
4998        let mut stream = agent
4999            .stream_prompt("use the tool")
5000            .add_hook(RetryDefaultApiHook)
5001            .max_turns(3)
5002            .max_invalid_tool_call_retries(0)
5003            .await;
5004        let mut error = None;
5005
5006        while let Some(item) = stream.next().await {
5007            if let Err(err) = item {
5008                error = Some(err);
5009                break;
5010            }
5011        }
5012
5013        let error = error.expect("retry budget exhaustion should fail");
5014        match error {
5015            StreamingError::Prompt(err) => match *err {
5016                PromptError::UnknownToolCall {
5017                    tool_name,
5018                    chat_history,
5019                    ..
5020                } => {
5021                    assert_eq!(tool_name, "default_api");
5022                    assert!(history_contains_tool_call(&chat_history, "default_api"));
5023                }
5024                other => panic!("expected UnknownToolCall, got {other:?}"),
5025            },
5026            other => panic!("expected prompt streaming error, got {other:?}"),
5027        }
5028        assert_eq!(recorded.request_count(), 1);
5029    }
5030
5031    #[tokio::test]
5032    async fn streaming_name_delta_retry_budget_exhaustion_history_includes_same_turn_context() {
5033        let model = MockCompletionModel::from_stream_turns([
5034            vec![
5035                MockStreamEvent::text("checking "),
5036                MockStreamEvent::tool_call(
5037                    "tool_call_0",
5038                    "add",
5039                    serde_json::json!({"x": 1, "y": 2}),
5040                )
5041                .with_call_id("call_0"),
5042                MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
5043                MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
5044                MockStreamEvent::final_response_with_total_tokens(4),
5045            ],
5046            vec![
5047                MockStreamEvent::text("should not be requested"),
5048                MockStreamEvent::final_response_with_total_tokens(6),
5049            ],
5050        ]);
5051        let recorded = model.clone();
5052        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5053
5054        let mut stream = agent
5055            .stream_prompt("use the tool")
5056            .add_hook(RetryDefaultApiHook)
5057            .max_turns(3)
5058            .max_invalid_tool_call_retries(0)
5059            .await;
5060        let mut error = None;
5061
5062        while let Some(item) = stream.next().await {
5063            if let Err(err) = item {
5064                error = Some(err);
5065                break;
5066            }
5067        }
5068
5069        let error = error.expect("retry budget exhaustion should fail");
5070        match error {
5071            StreamingError::Prompt(err) => match *err {
5072                PromptError::UnknownToolCall {
5073                    tool_name,
5074                    chat_history,
5075                    ..
5076                } => {
5077                    assert_eq!(tool_name, "default_api");
5078                    assert!(history_contains_text(&chat_history, "checking "));
5079                    assert!(history_contains_tool_call(&chat_history, "add"));
5080                    assert!(history_contains_tool_call(&chat_history, "default_api"));
5081                }
5082                other => panic!("expected UnknownToolCall, got {other:?}"),
5083            },
5084            other => panic!("expected prompt streaming error, got {other:?}"),
5085        }
5086        assert_eq!(recorded.request_count(), 1);
5087    }
5088
5089    #[tokio::test]
5090    async fn completed_unknown_tool_call_after_text_fails_before_finish_hook_or_later_emit() {
5091        let add_calls = Arc::new(AtomicU32::new(0));
5092        let model = MockCompletionModel::from_stream_turns([
5093            vec![
5094                MockStreamEvent::text("thinking "),
5095                MockStreamEvent::tool_call(
5096                    "tool_call_1",
5097                    "default_api",
5098                    serde_json::json!({"x": 1, "y": 2}),
5099                ),
5100                MockStreamEvent::final_response_with_total_tokens(4),
5101            ],
5102            vec![
5103                MockStreamEvent::text("should not be requested"),
5104                MockStreamEvent::final_response_with_total_tokens(6),
5105            ],
5106        ]);
5107        let recorded = model.clone();
5108        let agent = AgentBuilder::new(model)
5109            .tool(CountingAddTool {
5110                calls: add_calls.clone(),
5111            })
5112            .build();
5113
5114        let mut stream = agent
5115            .stream_prompt("use the tool")
5116            .add_hook(PanicOnUnknownToolHook)
5117            .max_turns(3)
5118            .await;
5119        let mut saw_text = false;
5120        let mut saw_completion_call = false;
5121        let mut saw_final_response = false;
5122        let mut saw_tool_call = false;
5123        let mut saw_tool_result = false;
5124        let mut error = None;
5125
5126        while let Some(item) = stream.next().await {
5127            match item {
5128                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(_))) => {
5129                    saw_text = true;
5130                }
5131                Ok(MultiTurnStreamItem::CompletionCall(_)) => {
5132                    saw_completion_call = true;
5133                }
5134                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Final(
5135                    _,
5136                )))
5137                | Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5138                    saw_final_response = true;
5139                }
5140                Ok(MultiTurnStreamItem::StreamAssistantItem(
5141                    StreamedAssistantContent::ToolCall { .. },
5142                )) => {
5143                    saw_tool_call = true;
5144                }
5145                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5146                    ..
5147                })) => {
5148                    saw_tool_result = true;
5149                }
5150                Ok(_) => {}
5151                Err(err) => {
5152                    error = Some(err);
5153                    break;
5154                }
5155            }
5156        }
5157
5158        assert!(saw_text);
5159        assert!(!saw_completion_call);
5160        assert!(!saw_final_response);
5161        assert!(!saw_tool_call);
5162        assert!(!saw_tool_result);
5163        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
5164        let error = error.expect("completed unknown tool call should fail immediately");
5165        match error {
5166            StreamingError::Prompt(err) => match *err {
5167                PromptError::UnknownToolCall {
5168                    tool_name,
5169                    available_tools,
5170                    allowed_tools,
5171                    chat_history,
5172                } => {
5173                    assert_eq!(tool_name, "default_api");
5174                    assert_eq!(available_tools, vec!["add".to_string()]);
5175                    assert_eq!(allowed_tools, vec!["add".to_string()]);
5176                    assert!(history_contains_tool_call(&chat_history, "default_api"));
5177                }
5178                other => panic!("expected UnknownToolCall, got {other:?}"),
5179            },
5180            other => panic!("expected prompt streaming error, got {other:?}"),
5181        }
5182        assert_eq!(recorded.request_count(), 1);
5183    }
5184
5185    #[tokio::test]
5186    async fn mixed_streaming_tool_calls_fail_before_any_tool_execution() {
5187        let add_calls = Arc::new(AtomicU32::new(0));
5188        let model = MockCompletionModel::from_stream_turns([
5189            vec![
5190                MockStreamEvent::tool_call(
5191                    "tool_call_1",
5192                    "add",
5193                    serde_json::json!({"x": 1, "y": 2}),
5194                )
5195                .with_call_id("call_1"),
5196                MockStreamEvent::tool_call(
5197                    "tool_call_2",
5198                    "default_api",
5199                    serde_json::json!({"x": 3, "y": 4}),
5200                ),
5201                MockStreamEvent::final_response_with_total_tokens(4),
5202            ],
5203            vec![
5204                MockStreamEvent::text("should not be requested"),
5205                MockStreamEvent::final_response_with_total_tokens(6),
5206            ],
5207        ]);
5208        let recorded = model.clone();
5209        let agent = AgentBuilder::new(model)
5210            .tool(CountingAddTool {
5211                calls: add_calls.clone(),
5212            })
5213            .build();
5214
5215        let mut stream = agent
5216            .stream_prompt("use tools")
5217            .add_hook(PanicOnUnknownToolHook)
5218            .max_turns(3)
5219            .await;
5220        let mut saw_completion_call = false;
5221        let mut saw_tool_call = false;
5222        let mut saw_tool_result = false;
5223        let mut error = None;
5224
5225        while let Some(item) = stream.next().await {
5226            match item {
5227                Ok(MultiTurnStreamItem::CompletionCall(_)) => {
5228                    saw_completion_call = true;
5229                }
5230                Ok(MultiTurnStreamItem::StreamAssistantItem(
5231                    StreamedAssistantContent::ToolCall { .. },
5232                )) => {
5233                    saw_tool_call = true;
5234                }
5235                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5236                    ..
5237                })) => {
5238                    saw_tool_result = true;
5239                }
5240                Ok(_) => {}
5241                Err(err) => {
5242                    error = Some(err);
5243                    break;
5244                }
5245            }
5246        }
5247
5248        assert!(!saw_completion_call);
5249        assert!(!saw_tool_call);
5250        assert!(!saw_tool_result);
5251        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
5252        let error = error.expect("mixed unknown streamed tool call should fail");
5253        match error {
5254            StreamingError::Prompt(err) => match *err {
5255                PromptError::UnknownToolCall {
5256                    tool_name,
5257                    available_tools,
5258                    allowed_tools,
5259                    chat_history,
5260                } => {
5261                    assert_eq!(tool_name, "default_api");
5262                    assert_eq!(available_tools, vec!["add".to_string()]);
5263                    assert_eq!(allowed_tools, vec!["add".to_string()]);
5264                    assert!(history_contains_tool_call(&chat_history, "default_api"));
5265                }
5266                other => panic!("expected UnknownToolCall, got {other:?}"),
5267            },
5268            other => panic!("expected prompt streaming error, got {other:?}"),
5269        }
5270        assert_eq!(recorded.request_count(), 1);
5271    }
5272
5273    #[tokio::test]
5274    async fn multiple_valid_streaming_tool_calls_execute_after_batch_validation() {
5275        let add_calls = Arc::new(AtomicU32::new(0));
5276        let subtract_calls = Arc::new(AtomicU32::new(0));
5277        let model = MockCompletionModel::from_stream_turns([
5278            vec![
5279                MockStreamEvent::tool_call(
5280                    "tool_call_1",
5281                    "add",
5282                    serde_json::json!({"x": 1, "y": 2}),
5283                )
5284                .with_call_id("call_1"),
5285                MockStreamEvent::tool_call(
5286                    "tool_call_2",
5287                    "subtract",
5288                    serde_json::json!({"x": 8, "y": 3}),
5289                )
5290                .with_call_id("call_2"),
5291                MockStreamEvent::final_response_with_total_tokens(4),
5292            ],
5293            vec![
5294                MockStreamEvent::text("done"),
5295                MockStreamEvent::final_response_with_total_tokens(6),
5296            ],
5297        ]);
5298        let recorded = model.clone();
5299        let agent = AgentBuilder::new(model)
5300            .tool(CountingAddTool {
5301                calls: add_calls.clone(),
5302            })
5303            .tool(CountingSubtractTool {
5304                calls: subtract_calls.clone(),
5305            })
5306            .build();
5307
5308        let mut stream = agent.stream_prompt("use tools").max_turns(3).await;
5309        let mut tool_call_names = Vec::new();
5310        let mut tool_result_ids = Vec::new();
5311        let mut final_response_text = None;
5312
5313        while let Some(item) = stream.next().await {
5314            match item {
5315                Ok(MultiTurnStreamItem::StreamAssistantItem(
5316                    StreamedAssistantContent::ToolCall { tool_call, .. },
5317                )) => {
5318                    tool_call_names.push(tool_call.function.name);
5319                }
5320                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5321                    tool_result,
5322                    ..
5323                })) => {
5324                    tool_result_ids.push(tool_result.call.into_string());
5325                }
5326                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5327                    final_response_text = Some(response.output().to_owned());
5328                    break;
5329                }
5330                Ok(_) => {}
5331                Err(err) => panic!("unexpected streaming error: {err:?}"),
5332            }
5333        }
5334
5335        assert_eq!(
5336            tool_call_names,
5337            vec!["add".to_string(), "subtract".to_string()]
5338        );
5339        // The correlators drive the durable ids the results answer with.
5340        assert_eq!(
5341            tool_result_ids,
5342            vec!["call_1".to_string(), "call_2".to_string()]
5343        );
5344        assert_eq!(add_calls.load(Ordering::SeqCst), 1);
5345        assert_eq!(subtract_calls.load(Ordering::SeqCst), 1);
5346        assert_eq!(final_response_text.as_deref(), Some("done"));
5347        assert_eq!(recorded.request_count(), 2);
5348    }
5349
5350    #[tokio::test]
5351    async fn disallowed_specific_tool_call_fails_before_streaming_second_request() {
5352        let model = MockCompletionModel::from_stream_turns([
5353            vec![
5354                MockStreamEvent::tool_call(
5355                    "tool_call_1",
5356                    "subtract",
5357                    serde_json::json!({"x": 3, "y": 1}),
5358                ),
5359                MockStreamEvent::final_response_with_total_tokens(4),
5360            ],
5361            vec![
5362                MockStreamEvent::text("should not be requested"),
5363                MockStreamEvent::final_response_with_total_tokens(6),
5364            ],
5365        ]);
5366        let recorded = model.clone();
5367        let agent = AgentBuilder::new(model)
5368            .tool(MockAddTool)
5369            .tool(MockSubtractTool)
5370            .tool_choice(ToolChoice::Specific {
5371                function_names: vec!["add".to_string()],
5372            })
5373            .build();
5374
5375        let mut stream = agent
5376            .stream_prompt("use the allowed tool")
5377            .add_hook(PanicOnUnknownToolHook)
5378            .max_turns(3)
5379            .await;
5380        let mut saw_tool_call = false;
5381        let mut error = None;
5382
5383        while let Some(item) = stream.next().await {
5384            match item {
5385                Ok(MultiTurnStreamItem::StreamAssistantItem(
5386                    StreamedAssistantContent::ToolCall { .. },
5387                )) => {
5388                    saw_tool_call = true;
5389                }
5390                Ok(_) => {}
5391                Err(err) => {
5392                    error = Some(err);
5393                    break;
5394                }
5395            }
5396        }
5397
5398        assert!(!saw_tool_call);
5399        let error = error.expect("disallowed model-emitted tool should fail");
5400        match error {
5401            StreamingError::Prompt(err) => match *err {
5402                PromptError::UnknownToolCall {
5403                    tool_name,
5404                    available_tools,
5405                    allowed_tools,
5406                    chat_history,
5407                } => {
5408                    assert_eq!(tool_name, "subtract");
5409                    assert_eq!(
5410                        available_tools,
5411                        vec!["add".to_string(), "subtract".to_string()]
5412                    );
5413                    assert_eq!(allowed_tools, vec!["add".to_string()]);
5414                    assert!(history_contains_tool_call(&chat_history, "subtract"));
5415                }
5416                other => panic!("expected UnknownToolCall, got {other:?}"),
5417            },
5418            other => panic!("expected prompt streaming error, got {other:?}"),
5419        }
5420        assert_eq!(recorded.request_count(), 1);
5421    }
5422
5423    #[tokio::test]
5424    async fn mixed_specific_tool_calls_fail_before_any_tool_execution() {
5425        let add_calls = Arc::new(AtomicU32::new(0));
5426        let model = MockCompletionModel::from_stream_turns([
5427            vec![
5428                MockStreamEvent::tool_call(
5429                    "tool_call_1",
5430                    "add",
5431                    serde_json::json!({"x": 1, "y": 2}),
5432                ),
5433                MockStreamEvent::tool_call(
5434                    "tool_call_2",
5435                    "subtract",
5436                    serde_json::json!({"x": 3, "y": 1}),
5437                ),
5438                MockStreamEvent::final_response_with_total_tokens(4),
5439            ],
5440            vec![
5441                MockStreamEvent::text("should not be requested"),
5442                MockStreamEvent::final_response_with_total_tokens(6),
5443            ],
5444        ]);
5445        let recorded = model.clone();
5446        let agent = AgentBuilder::new(model)
5447            .tool(CountingAddTool {
5448                calls: add_calls.clone(),
5449            })
5450            .tool(MockSubtractTool)
5451            .tool_choice(ToolChoice::Specific {
5452                function_names: vec!["add".to_string()],
5453            })
5454            .build();
5455
5456        let mut stream = agent
5457            .stream_prompt("use the allowed tool")
5458            .add_hook(PanicOnUnknownToolHook)
5459            .max_turns(3)
5460            .await;
5461        let mut saw_tool_call = false;
5462        let mut saw_tool_result = false;
5463        let mut error = None;
5464
5465        while let Some(item) = stream.next().await {
5466            match item {
5467                Ok(MultiTurnStreamItem::StreamAssistantItem(
5468                    StreamedAssistantContent::ToolCall { .. },
5469                )) => {
5470                    saw_tool_call = true;
5471                }
5472                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5473                    ..
5474                })) => {
5475                    saw_tool_result = true;
5476                }
5477                Ok(_) => {}
5478                Err(err) => {
5479                    error = Some(err);
5480                    break;
5481                }
5482            }
5483        }
5484
5485        assert!(!saw_tool_call);
5486        assert!(!saw_tool_result);
5487        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
5488        let error = error.expect("mixed disallowed streamed tool call should fail");
5489        match error {
5490            StreamingError::Prompt(err) => match *err {
5491                PromptError::UnknownToolCall {
5492                    tool_name,
5493                    available_tools,
5494                    allowed_tools,
5495                    chat_history,
5496                } => {
5497                    assert_eq!(tool_name, "subtract");
5498                    assert_eq!(
5499                        available_tools,
5500                        vec!["add".to_string(), "subtract".to_string()]
5501                    );
5502                    assert_eq!(allowed_tools, vec!["add".to_string()]);
5503                    assert!(history_contains_tool_call(&chat_history, "subtract"));
5504                }
5505                other => panic!("expected UnknownToolCall, got {other:?}"),
5506            },
5507            other => panic!("expected prompt streaming error, got {other:?}"),
5508        }
5509        assert_eq!(recorded.request_count(), 1);
5510    }
5511
5512    #[tokio::test]
5513    async fn tool_choice_none_rejects_streaming_tool_call() {
5514        let model = MockCompletionModel::from_stream_turns([
5515            vec![
5516                MockStreamEvent::tool_call(
5517                    "tool_call_1",
5518                    "add",
5519                    serde_json::json!({"x": 1, "y": 2}),
5520                ),
5521                MockStreamEvent::final_response_with_total_tokens(4),
5522            ],
5523            vec![
5524                MockStreamEvent::text("should not be requested"),
5525                MockStreamEvent::final_response_with_total_tokens(6),
5526            ],
5527        ]);
5528        let recorded = model.clone();
5529        let agent = AgentBuilder::new(model)
5530            .tool(MockAddTool)
5531            .tool_choice(ToolChoice::None)
5532            .build();
5533
5534        let mut stream = agent
5535            .stream_prompt("do not use tools")
5536            .add_hook(PanicOnUnknownToolHook)
5537            .max_turns(3)
5538            .await;
5539        let mut saw_tool_call = false;
5540        let mut error = None;
5541
5542        while let Some(item) = stream.next().await {
5543            match item {
5544                Ok(MultiTurnStreamItem::StreamAssistantItem(
5545                    StreamedAssistantContent::ToolCall { .. },
5546                )) => {
5547                    saw_tool_call = true;
5548                }
5549                Ok(_) => {}
5550                Err(err) => {
5551                    error = Some(err);
5552                    break;
5553                }
5554            }
5555        }
5556
5557        assert!(!saw_tool_call);
5558        let error = error.expect("ToolChoice::None should reject returned tool calls");
5559        match error {
5560            StreamingError::Prompt(err) => match *err {
5561                PromptError::UnknownToolCall {
5562                    tool_name,
5563                    available_tools,
5564                    allowed_tools,
5565                    chat_history,
5566                } => {
5567                    assert_eq!(tool_name, "add");
5568                    assert_eq!(available_tools, vec!["add".to_string()]);
5569                    assert!(allowed_tools.is_empty());
5570                    assert!(history_contains_tool_call(&chat_history, "add"));
5571                }
5572                other => panic!("expected UnknownToolCall, got {other:?}"),
5573            },
5574            other => panic!("expected prompt streaming error, got {other:?}"),
5575        }
5576        assert_eq!(recorded.request_count(), 1);
5577    }
5578
5579    #[tokio::test]
5580    async fn tool_choice_none_rejects_streaming_tool_call_name_delta_before_hook_or_emit() {
5581        let model = MockCompletionModel::from_stream_turns([
5582            vec![
5583                MockStreamEvent::tool_call_name_delta("tool_1", "add"),
5584                MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5585                MockStreamEvent::final_response_with_total_tokens(4),
5586            ],
5587            vec![
5588                MockStreamEvent::text("should not be requested"),
5589                MockStreamEvent::final_response_with_total_tokens(6),
5590            ],
5591        ]);
5592        let recorded = model.clone();
5593        let agent = AgentBuilder::new(model)
5594            .tool(MockAddTool)
5595            .tool_choice(ToolChoice::None)
5596            .build();
5597
5598        let mut stream = agent
5599            .stream_prompt("do not use tools")
5600            .add_hook(PanicOnUnknownToolHook)
5601            .max_turns(3)
5602            .await;
5603        let mut saw_delta = false;
5604        let mut error = None;
5605
5606        while let Some(item) = stream.next().await {
5607            match item {
5608                Ok(MultiTurnStreamItem::StreamAssistantItem(
5609                    StreamedAssistantContent::ToolCallDelta { .. },
5610                )) => {
5611                    saw_delta = true;
5612                }
5613                Ok(_) => {}
5614                Err(err) => {
5615                    error = Some(err);
5616                    break;
5617                }
5618            }
5619        }
5620
5621        assert!(!saw_delta);
5622        let error = error.expect("ToolChoice::None should reject returned tool-call deltas");
5623        match error {
5624            StreamingError::Prompt(err) => match *err {
5625                PromptError::UnknownToolCall {
5626                    tool_name,
5627                    available_tools,
5628                    allowed_tools,
5629                    chat_history,
5630                } => {
5631                    assert_eq!(tool_name, "add");
5632                    assert_eq!(available_tools, vec!["add".to_string()]);
5633                    assert!(allowed_tools.is_empty());
5634                    assert!(history_contains_tool_call(&chat_history, "add"));
5635                }
5636                other => panic!("expected UnknownToolCall, got {other:?}"),
5637            },
5638            other => panic!("expected prompt streaming error, got {other:?}"),
5639        }
5640        assert_eq!(recorded.request_count(), 1);
5641    }
5642
5643    #[tokio::test]
5644    async fn unknown_tool_call_name_delta_fails_before_streaming_delta_hook_or_emit() {
5645        let model = MockCompletionModel::from_stream_turns([
5646            vec![
5647                MockStreamEvent::tool_call_name_delta("tool_1", "default_api"),
5648                MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5649                MockStreamEvent::final_response_with_total_tokens(4),
5650            ],
5651            vec![
5652                MockStreamEvent::text("should not be requested"),
5653                MockStreamEvent::final_response_with_total_tokens(6),
5654            ],
5655        ]);
5656        let recorded = model.clone();
5657        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5658
5659        let mut stream = agent
5660            .stream_prompt("stream a bad tool call")
5661            .add_hook(PanicOnUnknownToolHook)
5662            .max_turns(3)
5663            .await;
5664        let mut saw_delta = false;
5665        let mut error = None;
5666
5667        while let Some(item) = stream.next().await {
5668            match item {
5669                Ok(MultiTurnStreamItem::StreamAssistantItem(
5670                    StreamedAssistantContent::ToolCallDelta { .. },
5671                )) => {
5672                    saw_delta = true;
5673                }
5674                Ok(_) => {}
5675                Err(err) => {
5676                    error = Some(err);
5677                    break;
5678                }
5679            }
5680        }
5681
5682        assert!(!saw_delta);
5683        let error = error.expect("unknown tool-call name delta should fail");
5684        match error {
5685            StreamingError::Prompt(err) => match *err {
5686                PromptError::UnknownToolCall {
5687                    tool_name,
5688                    available_tools,
5689                    allowed_tools,
5690                    chat_history,
5691                } => {
5692                    assert_eq!(tool_name, "default_api");
5693                    assert_eq!(available_tools, vec!["add".to_string()]);
5694                    assert_eq!(allowed_tools, vec!["add".to_string()]);
5695                    assert!(history_contains_tool_call(&chat_history, "default_api"));
5696                }
5697                other => panic!("expected UnknownToolCall, got {other:?}"),
5698            },
5699            other => panic!("expected prompt streaming error, got {other:?}"),
5700        }
5701        assert_eq!(recorded.request_count(), 1);
5702    }
5703
5704    #[tokio::test]
5705    async fn tool_call_args_delta_before_unknown_name_fails_before_hook_or_emit() {
5706        let model = MockCompletionModel::from_stream_turns([
5707            vec![
5708                MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5709                MockStreamEvent::tool_call_name_delta("tool_1", "default_api"),
5710                MockStreamEvent::final_response_with_total_tokens(4),
5711            ],
5712            vec![
5713                MockStreamEvent::text("should not be requested"),
5714                MockStreamEvent::final_response_with_total_tokens(6),
5715            ],
5716        ]);
5717        let recorded = model.clone();
5718        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5719
5720        let mut stream = agent
5721            .stream_prompt("stream a bad tool call")
5722            .add_hook(PanicOnUnknownToolHook)
5723            .max_turns(3)
5724            .await;
5725        let mut saw_delta = false;
5726        let mut error = None;
5727
5728        while let Some(item) = stream.next().await {
5729            match item {
5730                Ok(MultiTurnStreamItem::StreamAssistantItem(
5731                    StreamedAssistantContent::ToolCallDelta { .. },
5732                )) => {
5733                    saw_delta = true;
5734                }
5735                Ok(_) => {}
5736                Err(err) => {
5737                    error = Some(err);
5738                    break;
5739                }
5740            }
5741        }
5742
5743        assert!(!saw_delta);
5744        let error = error.expect("unknown tool-call name should reject buffered args");
5745        match error {
5746            StreamingError::Prompt(err) => match *err {
5747                PromptError::UnknownToolCall {
5748                    tool_name,
5749                    available_tools,
5750                    allowed_tools,
5751                    chat_history,
5752                } => {
5753                    assert_eq!(tool_name, "default_api");
5754                    assert_eq!(available_tools, vec!["add".to_string()]);
5755                    assert_eq!(allowed_tools, vec!["add".to_string()]);
5756                    assert!(history_contains_tool_call(&chat_history, "default_api"));
5757                }
5758                other => panic!("expected UnknownToolCall, got {other:?}"),
5759            },
5760            other => panic!("expected prompt streaming error, got {other:?}"),
5761        }
5762        assert_eq!(recorded.request_count(), 1);
5763    }
5764
5765    #[tokio::test]
5766    async fn tool_call_args_delta_before_valid_name_buffers_then_emits_in_safe_order() {
5767        let model = MockCompletionModel::from_stream_turns([[
5768            MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
5769            MockStreamEvent::tool_call_name_delta("tool_1", "add"),
5770            MockStreamEvent::tool_call_arguments_delta("tool_1", "1}"),
5771            MockStreamEvent::final_response_with_total_tokens(3),
5772        ]]);
5773        let hook = RecordingToolCallDeltaHook::default();
5774        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5775
5776        let mut stream = agent
5777            .stream_prompt("stream a tool call")
5778            .add_hook(hook.clone())
5779            .await;
5780        let mut stream_deltas = Vec::new();
5781
5782        while let Some(item) = stream.next().await {
5783            match item {
5784                Ok(MultiTurnStreamItem::StreamAssistantItem(
5785                    StreamedAssistantContent::ToolCallDelta {
5786                        internal_call_id,
5787                        content,
5788                    },
5789                )) => {
5790                    stream_deltas.push((internal_call_id, content));
5791                }
5792                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5793                Ok(_) => {}
5794                Err(err) => panic!("unexpected streaming error: {err:?}"),
5795            }
5796        }
5797
5798        // The internal call id is minted by the shared accumulator when the
5799        // call opens; assert correlation (one stable id across every delta)
5800        // rather than a scripted literal.
5801        let internal = stream_deltas
5802            .first()
5803            .map(|delta| delta.0.clone())
5804            .expect("at least one delta");
5805        assert!(!internal.is_empty());
5806        assert_eq!(
5807            hook.observed(),
5808            vec![
5809                (internal.clone(), Some("add".to_string()), String::new()),
5810                (internal.clone(), None, "{\"x\":".to_string()),
5811                (internal.clone(), None, "1}".to_string()),
5812            ]
5813        );
5814        assert_eq!(
5815            stream_deltas,
5816            vec![
5817                (
5818                    internal.clone(),
5819                    ToolCallDeltaContent::Name("add".to_string())
5820                ),
5821                (
5822                    internal.clone(),
5823                    ToolCallDeltaContent::Delta("{\"x\":".to_string())
5824                ),
5825                (
5826                    internal.clone(),
5827                    ToolCallDeltaContent::Delta("1}".to_string())
5828                ),
5829            ]
5830        );
5831    }
5832
5833    #[tokio::test]
5834    async fn tool_call_args_delta_without_name_errors_at_stream_end() {
5835        let model = MockCompletionModel::from_stream_turns([
5836            vec![
5837                MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5838                MockStreamEvent::final_response_with_total_tokens(4),
5839            ],
5840            vec![
5841                MockStreamEvent::text("should not be requested"),
5842                MockStreamEvent::final_response_with_total_tokens(6),
5843            ],
5844        ]);
5845        let recorded = model.clone();
5846        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5847
5848        let mut stream = agent
5849            .stream_prompt("stream an incomplete tool call")
5850            .add_hook(PanicOnUnknownToolHook)
5851            .max_turns(3)
5852            .await;
5853        let mut saw_delta = false;
5854        let mut saw_completion_call = false;
5855        let mut saw_final_response = false;
5856        let mut error = None;
5857
5858        while let Some(item) = stream.next().await {
5859            match item {
5860                Ok(MultiTurnStreamItem::StreamAssistantItem(
5861                    StreamedAssistantContent::ToolCallDelta { .. },
5862                )) => {
5863                    saw_delta = true;
5864                }
5865                Ok(MultiTurnStreamItem::CompletionCall(_)) => {
5866                    saw_completion_call = true;
5867                }
5868                Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5869                    saw_final_response = true;
5870                }
5871                Ok(_) => {}
5872                Err(err) => {
5873                    error = Some(err);
5874                    break;
5875                }
5876            }
5877        }
5878
5879        assert!(!saw_delta);
5880        assert!(!saw_completion_call);
5881        assert!(!saw_final_response);
5882        let error = error.expect("unterminated tool-call args delta should fail");
5883        match error {
5884            StreamingError::Completion(CompletionError::ResponseError(message)) => {
5885                assert!(
5886                    message.contains("streamed tool call arguments"),
5887                    "{message}"
5888                );
5889                // The diagnostic names the rig correlator (present and
5890                // non-empty); no stream key or fabricated provider id.
5891                assert!(message.contains("internal_call_id"), "{message}");
5892            }
5893            other => panic!("expected completion response error, got {other:?}"),
5894        }
5895        assert_eq!(recorded.request_count(), 1);
5896    }
5897
5898    #[tokio::test]
5899    async fn tool_choice_none_buffers_args_then_rejects_name_without_emit() {
5900        let model = MockCompletionModel::from_stream_turns([
5901            vec![
5902                MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5903                MockStreamEvent::tool_call_name_delta("tool_1", "add"),
5904                MockStreamEvent::final_response_with_total_tokens(4),
5905            ],
5906            vec![
5907                MockStreamEvent::text("should not be requested"),
5908                MockStreamEvent::final_response_with_total_tokens(6),
5909            ],
5910        ]);
5911        let recorded = model.clone();
5912        let agent = AgentBuilder::new(model)
5913            .tool(MockAddTool)
5914            .tool_choice(ToolChoice::None)
5915            .build();
5916
5917        let mut stream = agent
5918            .stream_prompt("do not use tools")
5919            .add_hook(PanicOnUnknownToolHook)
5920            .max_turns(3)
5921            .await;
5922        let mut saw_delta = false;
5923        let mut error = None;
5924
5925        while let Some(item) = stream.next().await {
5926            match item {
5927                Ok(MultiTurnStreamItem::StreamAssistantItem(
5928                    StreamedAssistantContent::ToolCallDelta { .. },
5929                )) => {
5930                    saw_delta = true;
5931                }
5932                Ok(_) => {}
5933                Err(err) => {
5934                    error = Some(err);
5935                    break;
5936                }
5937            }
5938        }
5939
5940        assert!(!saw_delta);
5941        let error = error.expect("ToolChoice::None should reject buffered tool-call deltas");
5942        match error {
5943            StreamingError::Prompt(err) => match *err {
5944                PromptError::UnknownToolCall {
5945                    tool_name,
5946                    available_tools,
5947                    allowed_tools,
5948                    chat_history,
5949                } => {
5950                    assert_eq!(tool_name, "add");
5951                    assert_eq!(available_tools, vec!["add".to_string()]);
5952                    assert!(allowed_tools.is_empty());
5953                    assert!(history_contains_tool_call(&chat_history, "add"));
5954                }
5955                other => panic!("expected UnknownToolCall, got {other:?}"),
5956            },
5957            other => panic!("expected prompt streaming error, got {other:?}"),
5958        }
5959        assert_eq!(recorded.request_count(), 1);
5960    }
5961
5962    #[tokio::test]
5963    async fn stream_prompt_observes_interleaved_reasoning_deltas_before_unchanged_emit() {
5964        let model = MockCompletionModel::from_stream_turns([[
5965            MockStreamEvent::reasoning_delta("first "),
5966            MockStreamEvent::reasoning_delta_with_id("rs_b", "beta"),
5967            MockStreamEvent::reasoning_delta("second"),
5968            MockStreamEvent::reasoning("first second"),
5969            MockStreamEvent::final_response_with_total_tokens(3),
5970        ]]);
5971        let hook = RecordingReasoningDeltaHook::default();
5972        let agent = AgentBuilder::new(model).build();
5973
5974        let mut stream = agent
5975            .stream_prompt("reason about this")
5976            .add_hook(hook.clone())
5977            .await;
5978        let mut stream_deltas = Vec::new();
5979        let mut completed_reasoning = 0;
5980
5981        while let Some(item) = stream.next().await {
5982            match item {
5983                Ok(MultiTurnStreamItem::StreamAssistantItem(
5984                    StreamedAssistantContent::ReasoningDelta {
5985                        id,
5986                        provider_id,
5987                        reasoning,
5988                    },
5989                )) => stream_deltas.push((id, provider_id, reasoning)),
5990                Ok(MultiTurnStreamItem::StreamAssistantItem(
5991                    StreamedAssistantContent::Reasoning { .. },
5992                )) => completed_reasoning += 1,
5993                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5994                Ok(_) => {}
5995                Err(err) => panic!("unexpected streaming error: {err:?}"),
5996            }
5997        }
5998
5999        assert_eq!(stream_deltas.len(), 3);
6000        let first_id = stream_deltas[0].0.clone();
6001        let second_id = stream_deltas[1].0.clone();
6002        assert!(!first_id.is_empty());
6003        assert!(!second_id.is_empty());
6004        assert_ne!(first_id, second_id);
6005        assert_eq!(stream_deltas[2].0, first_id);
6006        assert_eq!(stream_deltas[0].1, None);
6007        assert_eq!(stream_deltas[1].1.as_deref(), Some("rs_b"));
6008        assert_eq!(stream_deltas[2].1, None);
6009        assert_eq!(
6010            stream_deltas
6011                .iter()
6012                .map(|(_, _, delta)| delta.as_str())
6013                .collect::<Vec<_>>(),
6014            vec!["first ", "beta", "second"]
6015        );
6016        assert_eq!(completed_reasoning, 1);
6017        assert_eq!(
6018            hook.observed(),
6019            vec![
6020                (
6021                    first_id.clone(),
6022                    None,
6023                    "first ".to_string(),
6024                    "first ".to_string(),
6025                ),
6026                (
6027                    second_id,
6028                    Some("rs_b".to_string()),
6029                    "beta".to_string(),
6030                    "beta".to_string(),
6031                ),
6032                (
6033                    first_id,
6034                    None,
6035                    "second".to_string(),
6036                    "first second".to_string(),
6037                ),
6038            ]
6039        );
6040    }
6041
6042    #[tokio::test]
6043    async fn stream_prompt_reasoning_delta_stop_prevents_emit_and_later_hook_dispatch() {
6044        let model = MockCompletionModel::from_stream_turns([[
6045            MockStreamEvent::reasoning_delta_with_id("rs_1", "blocked"),
6046            MockStreamEvent::reasoning_delta_with_id("rs_1", "later"),
6047            MockStreamEvent::final_response_with_total_tokens(2),
6048        ]]);
6049        let stopping = TerminatingReasoningDeltaHook::default();
6050        let later = RecordingReasoningDeltaHook::default();
6051        let agent = AgentBuilder::new(model).build();
6052
6053        let mut stream = agent
6054            .stream_prompt("reason about this")
6055            .add_hook(stopping.clone())
6056            .add_hook(later.clone())
6057            .await;
6058        let mut saw_delta = false;
6059        let mut saw_final_response = false;
6060        let mut error_message = None;
6061
6062        while let Some(item) = stream.next().await {
6063            match item {
6064                Ok(MultiTurnStreamItem::StreamAssistantItem(
6065                    StreamedAssistantContent::ReasoningDelta { .. },
6066                )) => saw_delta = true,
6067                Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final_response = true,
6068                Ok(_) => {}
6069                Err(err) => {
6070                    error_message = Some(err.to_string());
6071                    break;
6072                }
6073            }
6074        }
6075
6076        let observed = stopping.observed();
6077        assert_eq!(observed.len(), 1);
6078        assert_eq!(observed[0].1.as_deref(), Some("rs_1"));
6079        assert_eq!(observed[0].2, "blocked");
6080        assert_eq!(observed[0].3, "blocked");
6081        assert!(later.observed().is_empty());
6082        assert!(!saw_delta);
6083        assert!(!saw_final_response);
6084        assert!(
6085            error_message.as_deref().is_some_and(|message| message
6086                .contains("PromptCancelled: stop on reasoning delta")),
6087            "expected hook termination error, got {error_message:?}"
6088        );
6089    }
6090
6091    #[tokio::test]
6092    async fn stream_prompt_skips_reasoning_delta_hook_without_observation_interest() {
6093        let model = MockCompletionModel::from_stream_turns([[
6094            MockStreamEvent::reasoning_delta("visible"),
6095            MockStreamEvent::final_response_with_total_tokens(1),
6096        ]]);
6097        let hook = UninterestedReasoningDeltaHook::default();
6098        let agent = AgentBuilder::new(model).build();
6099
6100        let mut stream = agent
6101            .stream_prompt("reason about this")
6102            .add_hook(hook.clone())
6103            .await;
6104        let mut emitted = Vec::new();
6105
6106        while let Some(item) = stream.next().await {
6107            match item {
6108                Ok(MultiTurnStreamItem::StreamAssistantItem(
6109                    StreamedAssistantContent::ReasoningDelta { reasoning, .. },
6110                )) => emitted.push(reasoning),
6111                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6112                Ok(_) => {}
6113                Err(err) => panic!("unexpected streaming error: {err:?}"),
6114            }
6115        }
6116
6117        assert_eq!(emitted, vec!["visible"]);
6118        assert_eq!(hook.calls.load(Ordering::SeqCst), 0);
6119    }
6120
6121    #[tokio::test]
6122    async fn stream_prompt_reasoning_delta_hook_observes_retried_turns_as_provisional() {
6123        let model = MockCompletionModel::from_stream_turns([
6124            [
6125                MockStreamEvent::reasoning_delta("rejected"),
6126                MockStreamEvent::final_response_with_total_tokens(1),
6127            ],
6128            [
6129                MockStreamEvent::reasoning_delta("accepted"),
6130                MockStreamEvent::final_response_with_total_tokens(1),
6131            ],
6132        ]);
6133        let hook = RetryFirstReasoningTurnHook::default();
6134        let agent = AgentBuilder::new(model).build();
6135
6136        let mut stream = agent
6137            .stream_prompt("reason about this")
6138            .add_hook(hook.clone())
6139            .max_turns(2)
6140            .await;
6141        let mut order = Vec::new();
6142
6143        while let Some(item) = stream.next().await {
6144            match item {
6145                Ok(MultiTurnStreamItem::StreamAssistantItem(
6146                    StreamedAssistantContent::ReasoningDelta { reasoning, .. },
6147                )) => order.push(reasoning),
6148                Ok(MultiTurnStreamItem::ModelTurnRetried { turn }) => {
6149                    order.push(format!("retry:{turn}"));
6150                }
6151                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6152                Ok(_) => {}
6153                Err(err) => panic!("unexpected streaming error: {err:?}"),
6154            }
6155        }
6156
6157        assert_eq!(order, vec!["rejected", "retry:1", "accepted"]);
6158        let observed = hook.recorder.observed();
6159        assert_eq!(observed.len(), 2);
6160        assert_eq!(observed[0].2, "rejected");
6161        assert_eq!(observed[0].3, "rejected");
6162        assert_eq!(observed[1].2, "accepted");
6163        assert_eq!(observed[1].3, "accepted");
6164    }
6165
6166    #[tokio::test]
6167    async fn stream_prompt_emits_tool_call_deltas_without_hook() {
6168        let model = MockCompletionModel::from_stream_turns([[
6169            MockStreamEvent::tool_call_name_delta("tool_1", "add"),
6170            MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
6171            MockStreamEvent::tool_call_arguments_delta("tool_1", "1}"),
6172            MockStreamEvent::final_response_with_total_tokens(3),
6173        ]]);
6174        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6175
6176        let mut stream = agent.stream_prompt("stream a tool call").await;
6177        let mut deltas = Vec::new();
6178
6179        while let Some(item) = stream.next().await {
6180            match item {
6181                Ok(MultiTurnStreamItem::StreamAssistantItem(
6182                    StreamedAssistantContent::ToolCallDelta {
6183                        internal_call_id,
6184                        content,
6185                    },
6186                )) => {
6187                    deltas.push((internal_call_id, content));
6188                }
6189                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6190                Ok(_) => {}
6191                Err(err) => panic!("unexpected streaming error: {err:?}"),
6192            }
6193        }
6194
6195        // The internal call id is minted by the shared accumulator when the
6196        // call opens; assert correlation (one stable id across every delta)
6197        // rather than a scripted literal.
6198        let internal = deltas
6199            .first()
6200            .map(|delta| delta.0.clone())
6201            .expect("at least one delta");
6202        assert!(!internal.is_empty());
6203        assert_eq!(
6204            deltas,
6205            vec![
6206                (
6207                    internal.clone(),
6208                    ToolCallDeltaContent::Name("add".to_string())
6209                ),
6210                (
6211                    internal.clone(),
6212                    ToolCallDeltaContent::Delta("{\"x\":".to_string())
6213                ),
6214                (
6215                    internal.clone(),
6216                    ToolCallDeltaContent::Delta("1}".to_string())
6217                ),
6218            ]
6219        );
6220    }
6221
6222    #[tokio::test]
6223    async fn stream_prompt_emits_tool_call_deltas_after_hook_continue() {
6224        let model = MockCompletionModel::from_stream_turns([[
6225            MockStreamEvent::tool_call_name_delta("tool_1", "add"),
6226            MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
6227            MockStreamEvent::tool_call_arguments_delta("tool_1", "1}"),
6228            MockStreamEvent::final_response_with_total_tokens(3),
6229        ]]);
6230        let hook = RecordingToolCallDeltaHook::default();
6231        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6232
6233        let mut stream = agent
6234            .stream_prompt("stream a tool call")
6235            .add_hook(hook.clone())
6236            .await;
6237        let mut stream_deltas = Vec::new();
6238
6239        while let Some(item) = stream.next().await {
6240            match item {
6241                Ok(MultiTurnStreamItem::StreamAssistantItem(
6242                    StreamedAssistantContent::ToolCallDelta {
6243                        internal_call_id,
6244                        content,
6245                    },
6246                )) => {
6247                    stream_deltas.push((internal_call_id, content));
6248                }
6249                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6250                Ok(_) => {}
6251                Err(err) => panic!("unexpected streaming error: {err:?}"),
6252            }
6253        }
6254
6255        // The internal call id is minted by the shared accumulator when the
6256        // call opens; assert correlation (one stable id across every delta)
6257        // rather than a scripted literal.
6258        let internal = stream_deltas
6259            .first()
6260            .map(|delta| delta.0.clone())
6261            .expect("at least one delta");
6262        assert!(!internal.is_empty());
6263        assert_eq!(
6264            hook.observed(),
6265            vec![
6266                (internal.clone(), Some("add".to_string()), String::new()),
6267                (internal.clone(), None, "{\"x\":".to_string()),
6268                (internal.clone(), None, "1}".to_string()),
6269            ]
6270        );
6271        assert_eq!(
6272            stream_deltas,
6273            vec![
6274                (
6275                    internal.clone(),
6276                    ToolCallDeltaContent::Name("add".to_string())
6277                ),
6278                (
6279                    internal.clone(),
6280                    ToolCallDeltaContent::Delta("{\"x\":".to_string())
6281                ),
6282                (
6283                    internal.clone(),
6284                    ToolCallDeltaContent::Delta("1}".to_string())
6285                ),
6286            ]
6287        );
6288    }
6289
6290    #[tokio::test]
6291    async fn stream_prompt_tool_call_deltas_hook_termination_prevents_delta_emit() {
6292        let model = MockCompletionModel::from_stream_turns([[
6293            MockStreamEvent::tool_call_name_delta("tool_1", "add"),
6294            MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
6295            MockStreamEvent::final_response_with_total_tokens(3),
6296        ]]);
6297        let hook = TerminatingToolCallDeltaHook::default();
6298        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6299
6300        let mut stream = agent
6301            .stream_prompt("stream a tool call")
6302            .add_hook(hook.clone())
6303            .await;
6304        let mut saw_delta = false;
6305        let mut saw_final_response = false;
6306        let mut error_message = None;
6307
6308        while let Some(item) = stream.next().await {
6309            match item {
6310                Ok(MultiTurnStreamItem::StreamAssistantItem(
6311                    StreamedAssistantContent::ToolCallDelta { .. },
6312                )) => {
6313                    saw_delta = true;
6314                }
6315                Ok(MultiTurnStreamItem::FinalResponse(_)) => {
6316                    saw_final_response = true;
6317                }
6318                Ok(_) => {}
6319                Err(err) => {
6320                    error_message = Some(err.to_string());
6321                    break;
6322                }
6323            }
6324        }
6325
6326        // Internal ids are minted by the shared accumulator; assert presence,
6327        // not a scripted literal.
6328        let observed = hook.observed();
6329        assert_eq!(observed.len(), 1);
6330        let first = observed.first().expect("one observed delta");
6331        assert!(!first.0.is_empty());
6332        assert_eq!(first.1, Some("add".to_string()));
6333        assert_eq!(first.2, String::new());
6334        assert!(!saw_delta);
6335        assert!(!saw_final_response);
6336        assert!(
6337            error_message
6338                .as_deref()
6339                .is_some_and(|message| message.contains("PromptCancelled: stop on tool call delta")),
6340            "expected hook termination error, got {error_message:?}"
6341        );
6342    }
6343
6344    #[tokio::test]
6345    async fn stream_prompt_exposes_completion_calls() {
6346        let first_call_usage = usage(10, 2);
6347        let second_call_usage = usage(25, 5);
6348        let model = MockCompletionModel::from_stream_turns([
6349            vec![
6350                MockStreamEvent::tool_call(
6351                    "tool_call_1",
6352                    "add",
6353                    serde_json::json!({"x": 1, "y": 2}),
6354                )
6355                .with_call_id("call_1"),
6356                MockStreamEvent::final_response(first_call_usage),
6357            ],
6358            vec![
6359                MockStreamEvent::text("done"),
6360                MockStreamEvent::final_response(second_call_usage),
6361            ],
6362        ]);
6363        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6364        let empty_history: &[Message] = &[];
6365
6366        let mut stream = agent
6367            .stream_prompt("do tool work")
6368            .history(empty_history)
6369            .max_turns(3)
6370            .await;
6371        let mut completion_calls_events = Vec::new();
6372        let mut final_response = None;
6373
6374        while let Some(item) = stream.next().await {
6375            match item {
6376                Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
6377                    completion_calls_events.push(call_usage);
6378                }
6379                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
6380                    final_response = Some(response);
6381                    break;
6382                }
6383                Ok(_) => {}
6384                Err(err) => panic!("unexpected streaming error: {err:?}"),
6385            }
6386        }
6387
6388        assert_eq!(
6389            completion_calls_events,
6390            vec![
6391                streamed_call(0, first_call_usage),
6392                streamed_call(1, second_call_usage)
6393            ]
6394        );
6395
6396        let final_response = final_response.expect("expected final response");
6397        assert_eq!(
6398            final_response.usage(),
6399            Usage {
6400                input_tokens: 35,
6401                output_tokens: 7,
6402                total_tokens: 42,
6403                cached_input_tokens: 0,
6404                cache_creation_input_tokens: 0,
6405                tool_use_prompt_tokens: 0,
6406                reasoning_tokens: 0,
6407            }
6408        );
6409        assert_eq!(
6410            final_response.completion_calls(),
6411            &[
6412                streamed_call(0, first_call_usage),
6413                streamed_call(1, second_call_usage)
6414            ]
6415        );
6416    }
6417
6418    #[tokio::test(flavor = "current_thread")]
6419    async fn stream_prompt_records_single_call_usage_on_chat_span_under_outer_span() {
6420        let call_usage = usage(10, 2);
6421        let model = MockCompletionModel::from_stream_turns([[
6422            MockStreamEvent::text("done"),
6423            MockStreamEvent::final_response(call_usage),
6424        ]]);
6425        let agent = AgentBuilder::new(model).build();
6426
6427        assert_stream_usage_recorded_on_chat_spans(agent, "say done", 1, &[call_usage]).await;
6428    }
6429
6430    #[tokio::test(flavor = "current_thread")]
6431    async fn stream_prompt_records_multi_turn_usage_on_chat_spans_under_outer_span() {
6432        let first_call_usage = usage(10, 2);
6433        let second_call_usage = usage(25, 5);
6434        let model = MockCompletionModel::from_stream_turns([
6435            vec![
6436                MockStreamEvent::tool_call(
6437                    "tool_call_1",
6438                    "add",
6439                    serde_json::json!({"x": 1, "y": 2}),
6440                )
6441                .with_call_id("call_1"),
6442                MockStreamEvent::final_response(first_call_usage),
6443            ],
6444            vec![
6445                MockStreamEvent::text("done"),
6446                MockStreamEvent::final_response(second_call_usage),
6447            ],
6448        ]);
6449        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6450
6451        assert_stream_usage_recorded_on_chat_spans(
6452            agent,
6453            "do tool work",
6454            3,
6455            &[first_call_usage, second_call_usage],
6456        )
6457        .await;
6458    }
6459
6460    #[tokio::test]
6461    async fn stream_prompt_emits_completion_call_before_finish_hook_termination() {
6462        let call_usage = usage(10, 2);
6463        let model = MockCompletionModel::from_stream_turns([[
6464            MockStreamEvent::text("done"),
6465            MockStreamEvent::final_response(call_usage),
6466        ]]);
6467        let agent = AgentBuilder::new(model).build();
6468
6469        let mut stream = agent
6470            .stream_prompt("say done")
6471            .add_hook(TerminateOnStreamFinish)
6472            .await;
6473        let mut completion_calls = Vec::new();
6474        let mut saw_error = false;
6475
6476        while let Some(item) = stream.next().await {
6477            match item {
6478                Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
6479                    completion_calls.push(completion_call);
6480                }
6481                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
6482                    panic!("unexpected final response after hook termination: {response:?}");
6483                }
6484                Ok(_) => {}
6485                Err(_) => {
6486                    saw_error = true;
6487                    break;
6488                }
6489            }
6490        }
6491
6492        assert_eq!(completion_calls, vec![streamed_call(0, call_usage)]);
6493        assert!(saw_error);
6494    }
6495
6496    #[tokio::test]
6497    async fn stream_prompt_completion_calls_records_unreported_usage() {
6498        let second_call_usage = usage(25, 5);
6499        let model = MockCompletionModel::from_stream_turns([
6500            vec![
6501                MockStreamEvent::tool_call(
6502                    "tool_call_1",
6503                    "add",
6504                    serde_json::json!({"x": 1, "y": 2}),
6505                )
6506                .with_call_id("call_1"),
6507                // A genuine terminal whose usage is unreported: the completion
6508                // call records the zero-usage sentinel. (A turn with no
6509                // terminal at all is rejected as truncation instead.)
6510                MockStreamEvent::final_response(Usage::new()),
6511            ],
6512            vec![
6513                MockStreamEvent::text("done"),
6514                MockStreamEvent::final_response(second_call_usage),
6515            ],
6516        ]);
6517        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6518        let empty_history: &[Message] = &[];
6519
6520        let mut stream = agent
6521            .stream_prompt("do tool work")
6522            .history(empty_history)
6523            .max_turns(3)
6524            .await;
6525        let mut completion_calls_events = Vec::new();
6526        let mut final_response = None;
6527
6528        while let Some(item) = stream.next().await {
6529            match item {
6530                Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
6531                    completion_calls_events.push(call_usage);
6532                }
6533                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
6534                    final_response = Some(response);
6535                    break;
6536                }
6537                Ok(_) => {}
6538                Err(err) => panic!("unexpected streaming error: {err:?}"),
6539            }
6540        }
6541
6542        let expected_usage = vec![
6543            streamed_call(0, Usage::new()),
6544            streamed_call(1, second_call_usage),
6545        ];
6546        assert_eq!(completion_calls_events, expected_usage);
6547
6548        let final_response = final_response.expect("expected final response");
6549        assert_eq!(final_response.completion_calls(), expected_usage.as_slice());
6550    }
6551
6552    #[tokio::test]
6553    async fn final_response_matches_streamed_text_when_provider_final_is_textless() {
6554        let agent = AgentBuilder::new(streaming_text_then_final_model()).build();
6555
6556        let mut stream = agent.stream_prompt("say hello").await;
6557        let mut streamed_text = String::new();
6558        let mut final_response_text = None;
6559
6560        while let Some(item) = stream.next().await {
6561            match item {
6562                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
6563                    text,
6564                ))) => streamed_text.push_str(&text.text),
6565                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6566                    final_response_text = Some(res.output().to_owned());
6567                    break;
6568                }
6569                Ok(_) => {}
6570                Err(err) => panic!("unexpected streaming error: {err:?}"),
6571            }
6572        }
6573
6574        assert_eq!(streamed_text, "hello world");
6575        assert_eq!(final_response_text.as_deref(), Some("hello world"));
6576    }
6577
6578    #[tokio::test]
6579    async fn final_response_preserves_structured_text_metadata() {
6580        let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
6581
6582        let mut stream = agent.stream_prompt("answer with citations").await;
6583        let mut final_response = None;
6584
6585        while let Some(item) = stream.next().await {
6586            match item {
6587                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6588                    final_response = Some(res);
6589                    break;
6590                }
6591                Ok(_) => {}
6592                Err(err) => panic!("unexpected streaming error: {err:?}"),
6593            }
6594        }
6595
6596        let final_response = final_response.expect("expected final response");
6597        assert_eq!(final_response.output(), "cited answer");
6598        let metadata = text_metadata(final_response.content())
6599            .expect("expected text metadata in final content");
6600        assert_eq!(
6601            metadata["citations"][0]["encrypted_index"],
6602            "encrypted-reference"
6603        );
6604    }
6605
6606    #[tokio::test]
6607    async fn final_response_history_preserves_structured_text_metadata() {
6608        let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
6609
6610        let empty_history: &[Message] = &[];
6611        let mut stream = agent
6612            .stream_prompt("answer with citations")
6613            .history(empty_history)
6614            .await;
6615        let mut final_response = None;
6616
6617        while let Some(item) = stream.next().await {
6618            match item {
6619                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6620                    final_response = Some(res);
6621                    break;
6622                }
6623                Ok(_) => {}
6624                Err(err) => panic!("unexpected streaming error: {err:?}"),
6625            }
6626        }
6627
6628        let final_response = final_response.expect("expected final response");
6629        let history = final_response
6630            .messages()
6631            .expect("with_history should include final history");
6632        let assistant_content = history
6633            .iter()
6634            .find_map(|message| match message {
6635                Message::Assistant { content, .. } => Some(content),
6636                _ => None,
6637            })
6638            .expect("expected assistant message in history");
6639        let metadata =
6640            text_metadata(assistant_content).expect("expected text metadata in assistant history");
6641        assert_eq!(
6642            metadata["citations"][0]["encrypted_index"],
6643            "encrypted-reference"
6644        );
6645    }
6646
6647    #[tokio::test]
6648    async fn tool_follow_up_history_preserves_structured_text_metadata() {
6649        let model = streaming_cited_text_then_tool_model();
6650        let recorded = model.clone();
6651        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6652        let empty_history: &[Message] = &[];
6653
6654        let mut stream = agent
6655            .stream_prompt("use a tool with citations")
6656            .history(empty_history)
6657            .max_turns(3)
6658            .await;
6659
6660        while let Some(item) = stream.next().await {
6661            match item {
6662                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6663                Ok(_) => {}
6664                Err(err) => panic!("unexpected streaming error: {err:?}"),
6665            }
6666        }
6667
6668        let requests = recorded.requests();
6669        assert_eq!(requests.len(), 2);
6670        let follow_up_history = requests[1].chat_history.iter().collect::<Vec<_>>();
6671        let assistant_content = follow_up_history
6672            .iter()
6673            .find_map(|message| match message {
6674                Message::Assistant { content, .. } => Some(content),
6675                _ => None,
6676            })
6677            .expect("expected assistant message in follow-up history");
6678        let metadata = text_metadata(assistant_content)
6679            .expect("expected citation metadata in follow-up assistant history");
6680        assert_eq!(
6681            metadata["citations"][0]["encrypted_index"],
6682            "encrypted-reference"
6683        );
6684    }
6685
6686    #[tokio::test]
6687    async fn final_response_can_remain_empty_for_truly_textless_turns() {
6688        let agent = AgentBuilder::new(streaming_final_only_model()).build();
6689
6690        let mut stream = agent.stream_prompt("say nothing").await;
6691        let mut streamed_text = String::new();
6692        let mut final_response_text = None;
6693
6694        while let Some(item) = stream.next().await {
6695            match item {
6696                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
6697                    text,
6698                ))) => streamed_text.push_str(&text.text),
6699                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6700                    final_response_text = Some(res.output().to_owned());
6701                    break;
6702                }
6703                Ok(_) => {}
6704                Err(err) => panic!("unexpected streaming error: {err:?}"),
6705            }
6706        }
6707
6708        assert!(streamed_text.is_empty());
6709        assert_eq!(final_response_text.as_deref(), Some(""));
6710    }
6711
6712    /// rig#2322 — a turn that produced **nothing** and was cut short at the
6713    /// output-token limit must not finalize as a successful empty answer.
6714    ///
6715    /// Not a cassette test: a provider cannot be made to emit an exactly-empty
6716    /// `MAX_TOKENS` turn on demand, so the wire shape is scripted. The Gemini
6717    /// cassette suite pins the *request* side of rig#2322; this pins what the
6718    /// agent does with the response.
6719    ///
6720    /// This is the failure users actually saw. The 4096 cap truncated the turn,
6721    /// the assembler dropped `FinishReason::Length`, and the run finished as a
6722    /// successful `""` — a blank answer with no error and nothing to inspect.
6723    #[tokio::test]
6724    async fn empty_turn_truncated_at_max_tokens_is_an_error_not_an_empty_answer() {
6725        let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::FinalResponse(
6726            mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6727        )]]);
6728        let agent = AgentBuilder::new(model).build();
6729
6730        let mut stream = agent.stream_prompt("write a long essay").await;
6731        let mut error = None;
6732        let mut final_response_text = None;
6733
6734        while let Some(item) = stream.next().await {
6735            match item {
6736                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6737                    final_response_text = Some(res.output().to_owned());
6738                    break;
6739                }
6740                Ok(_) => {}
6741                Err(err) => {
6742                    error = Some(err);
6743                    break;
6744                }
6745            }
6746        }
6747
6748        assert!(
6749            final_response_text.is_none(),
6750            "a truncated, content-less turn must not finalize as a successful \
6751             answer — it did, yielding {final_response_text:?}"
6752        );
6753        let error = error.expect("the truncated turn should surface an error");
6754        let rendered = format!("{error:?}");
6755        assert!(
6756            rendered.contains("Length"),
6757            "the error must name the terminal reason so the cause is diagnosable, got: {rendered}"
6758        );
6759        assert!(
6760            rendered.contains("max_tokens"),
6761            "a budget truncation must point at the setting that fixes it: {rendered}"
6762        );
6763    }
6764
6765    /// rig#2322 — the guard against over-correcting the test above: a turn that
6766    /// streamed **real output** before hitting the limit stays valid.
6767    ///
6768    /// Truncation after partial output is a normal, useful result — the caller
6769    /// gets the prefix the model produced. Only a turn that delivered nothing
6770    /// is an error. Scripted for the same reason as above.
6771    #[tokio::test]
6772    async fn partial_output_truncated_at_max_tokens_stays_a_valid_answer() {
6773        let model = MockCompletionModel::from_stream_turns([[
6774            MockStreamEvent::Text("a partial ans".to_string()),
6775            MockStreamEvent::FinalResponse(
6776                mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6777            ),
6778        ]]);
6779        let agent = AgentBuilder::new(model).build();
6780
6781        let mut stream = agent.stream_prompt("write a long essay").await;
6782        let mut final_response = None;
6783
6784        while let Some(item) = stream.next().await {
6785            match item {
6786                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6787                    final_response = Some(res);
6788                    break;
6789                }
6790                Ok(_) => {}
6791                Err(err) => panic!("a truncated turn that produced text must not error: {err:?}"),
6792            }
6793        }
6794
6795        let final_response =
6796            final_response.expect("a turn with partial output should still finalize");
6797        assert_eq!(final_response.output(), "a partial ans");
6798
6799        // ...and the reason is preserved, so a caller can tell this answer was
6800        // cut short rather than complete.
6801        let truncated = final_response
6802            .completion_calls
6803            .iter()
6804            .any(|call| call.finish_reason == Some(FinishReason::Length));
6805        assert!(
6806            truncated,
6807            "the terminal reason must reach the caller on completion_calls; \
6808             without it a truncated answer is indistinguishable from a complete \
6809             one — calls: {:?}",
6810            final_response.completion_calls
6811        );
6812    }
6813
6814    /// rig#2322 — a content-filtered turn that delivered nothing gets the same
6815    /// treatment as a truncated one: it is not a successful empty answer.
6816    ///
6817    /// Scripted rather than recorded because a safety filter cannot be
6818    /// provoked reliably or ethically on demand.
6819    #[tokio::test]
6820    async fn empty_content_filtered_turn_is_an_error_not_an_empty_answer() {
6821        let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::FinalResponse(
6822            mock_final(Usage::new()).with_finish_reason(FinishReason::ContentFilter),
6823        )]]);
6824        let agent = AgentBuilder::new(model).build();
6825
6826        let mut stream = agent.stream_prompt("something the filter rejects").await;
6827        let mut errored = None;
6828
6829        while let Some(item) = stream.next().await {
6830            match item {
6831                Ok(MultiTurnStreamItem::FinalResponse(res)) => panic!(
6832                    "a content-filtered, content-less turn must not finalize as a \
6833                     successful answer, got {:?}",
6834                    res.output()
6835                ),
6836                Ok(_) => {}
6837                Err(err) => {
6838                    errored = Some(err);
6839                    break;
6840                }
6841            }
6842        }
6843
6844        let rendered = format!("{:?}", errored.expect("the filtered turn should error"));
6845        assert!(
6846            rendered.contains("ContentFilter"),
6847            "the error must name the terminal reason, got: {rendered}"
6848        );
6849        assert!(
6850            !rendered.contains("max_tokens"),
6851            "a safety block must not advise raising max_tokens — that setting \
6852             cannot fix a filtered response: {rendered}"
6853        );
6854    }
6855
6856    /// rig#2322 — the narrowing that keeps the rule from failing benign runs:
6857    /// a provider-specific `Other` reason is **not** treated as truncation.
6858    ///
6859    /// `Other` carries a provider's own wire spelling with no normalized
6860    /// meaning, so erroring on it would fail runs on stops rig does not model.
6861    #[tokio::test]
6862    async fn empty_turn_with_unmodeled_finish_reason_still_finalizes() {
6863        let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::FinalResponse(
6864            mock_final(Usage::new())
6865                .with_finish_reason(FinishReason::Other("PROVIDER_SPECIFIC".to_string())),
6866        )]]);
6867        let agent = AgentBuilder::new(model).build();
6868
6869        let mut stream = agent.stream_prompt("say nothing").await;
6870        let mut final_response_text = None;
6871
6872        while let Some(item) = stream.next().await {
6873            match item {
6874                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6875                    final_response_text = Some(res.output().to_owned());
6876                    break;
6877                }
6878                Ok(_) => {}
6879                Err(err) => panic!("an unmodeled finish reason must not error: {err:?}"),
6880            }
6881        }
6882
6883        assert_eq!(final_response_text.as_deref(), Some(""));
6884    }
6885
6886    /// rig#2322 — a turn that spent its whole budget **thinking** and was cut
6887    /// off before answering must error, not report success with `""`.
6888    ///
6889    /// This is the common shape of the bug, not a corner of it: Gemini counts
6890    /// thinking tokens against `maxOutputTokens` (the committed cassettes show
6891    /// `thoughtsTokenCount` of 176–307 on ordinary prompts), so a truncated
6892    /// thinking turn *typically* carries reasoning and no text.
6893    ///
6894    /// The first version of this guard keyed on `is_empty_assistant_turn`,
6895    /// which is false for a reasoning-only turn — so the headline scenario
6896    /// still finalized as a successful empty answer. The predicate is now
6897    /// `turn_delivered_no_answer`.
6898    ///
6899    /// Synthetic: a provider cannot be made to truncate mid-thought on demand.
6900    #[tokio::test]
6901    async fn reasoning_only_turn_truncated_at_max_tokens_is_an_error() {
6902        let model = MockCompletionModel::from_stream_turns([[
6903            MockStreamEvent::reasoning("thinking hard and never reaching an answer"),
6904            MockStreamEvent::FinalResponse(
6905                mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6906            ),
6907        ]]);
6908        let agent = AgentBuilder::new(model).build();
6909
6910        let mut stream = agent.stream_prompt("solve this carefully").await;
6911        let mut error = None;
6912
6913        while let Some(item) = stream.next().await {
6914            match item {
6915                Ok(MultiTurnStreamItem::FinalResponse(res)) => panic!(
6916                    "a turn that only produced reasoning before being truncated must \
6917                     not finalize as a successful answer, got {:?}",
6918                    res.output()
6919                ),
6920                Ok(_) => {}
6921                Err(err) => {
6922                    error = Some(err);
6923                    break;
6924                }
6925            }
6926        }
6927
6928        let rendered = format!("{:?}", error.expect("the truncated turn should error"));
6929        assert!(
6930            rendered.contains("Length"),
6931            "the error must name the terminal reason, got: {rendered}"
6932        );
6933    }
6934
6935    /// rig#2322 — the same shape under a content filter takes the same path.
6936    ///
6937    /// Synthetic for the same reason, plus: a safety filter cannot be provoked
6938    /// reliably or ethically on demand.
6939    #[tokio::test]
6940    async fn reasoning_only_turn_content_filtered_is_an_error() {
6941        let model = MockCompletionModel::from_stream_turns([[
6942            MockStreamEvent::reasoning("considering something the filter rejects"),
6943            MockStreamEvent::FinalResponse(
6944                mock_final(Usage::new()).with_finish_reason(FinishReason::ContentFilter),
6945            ),
6946        ]]);
6947        let agent = AgentBuilder::new(model).build();
6948
6949        let mut stream = agent.stream_prompt("something borderline").await;
6950        let mut errored = None;
6951
6952        while let Some(item) = stream.next().await {
6953            match item {
6954                Ok(MultiTurnStreamItem::FinalResponse(res)) => panic!(
6955                    "a reasoning-only filtered turn must not finalize successfully, \
6956                     got {:?}",
6957                    res.output()
6958                ),
6959                Ok(_) => {}
6960                Err(err) => {
6961                    errored = Some(err);
6962                    break;
6963                }
6964            }
6965        }
6966
6967        let rendered = format!(
6968            "{:?}",
6969            errored.expect("the filtered reasoning-only turn should error")
6970        );
6971        assert!(
6972            rendered.contains("ContentFilter") && !rendered.contains("max_tokens"),
6973            "a filtered turn must name its reason and must not advise raising \
6974             max_tokens: {rendered}"
6975        );
6976    }
6977
6978    /// rig#2322 — the guard against over-correcting into "reasoning present
6979    /// means failure": a turn that thought **and then answered** before being
6980    /// truncated is a valid answer.
6981    ///
6982    /// Synthetic: same reason as above.
6983    #[tokio::test]
6984    async fn reasoning_then_text_truncated_stays_a_valid_answer() {
6985        let model = MockCompletionModel::from_stream_turns([[
6986            MockStreamEvent::reasoning("weighing the options"),
6987            MockStreamEvent::Text("the answer so f".to_string()),
6988            MockStreamEvent::FinalResponse(
6989                mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6990            ),
6991        ]]);
6992        let agent = AgentBuilder::new(model).build();
6993
6994        let mut stream = agent.stream_prompt("solve this").await;
6995        let mut final_response = None;
6996
6997        while let Some(item) = stream.next().await {
6998            match item {
6999                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7000                    final_response = Some(res);
7001                    break;
7002                }
7003                Ok(_) => {}
7004                Err(err) => panic!("a truncated turn that produced text must not error: {err:?}"),
7005            }
7006        }
7007
7008        let final_response = final_response.expect("a turn with text should finalize");
7009        assert_eq!(final_response.output(), "the answer so f");
7010        assert!(
7011            final_response
7012                .completion_calls
7013                .iter()
7014                .any(|call| call.finish_reason == Some(FinishReason::Length)),
7015            "the terminal reason must still reach the caller on a valid truncated turn"
7016        );
7017    }
7018
7019    /// rig#2322 — a model that thought and legitimately had nothing to add is
7020    /// not an error. Only a *truncating* reason makes a reasoning-only turn a
7021    /// failure; a natural stop leaves it exactly as it was.
7022    ///
7023    /// Synthetic: same reason as above.
7024    #[tokio::test]
7025    async fn reasoning_only_turn_that_stopped_naturally_still_finalizes() {
7026        let model = MockCompletionModel::from_stream_turns([[
7027            MockStreamEvent::reasoning("thought about it, nothing to add"),
7028            MockStreamEvent::FinalResponse(
7029                mock_final(Usage::new()).with_finish_reason(FinishReason::Stop),
7030            ),
7031        ]]);
7032        let agent = AgentBuilder::new(model).build();
7033
7034        let mut stream = agent.stream_prompt("say nothing").await;
7035        let mut final_response_text = None;
7036
7037        while let Some(item) = stream.next().await {
7038            match item {
7039                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7040                    final_response_text = Some(res.output().to_owned());
7041                    break;
7042                }
7043                Ok(_) => {}
7044                Err(err) => panic!("a naturally-stopped reasoning turn must not error: {err:?}"),
7045            }
7046        }
7047
7048        assert_eq!(final_response_text.as_deref(), Some(""));
7049    }
7050
7051    /// rig#2322 — what happens to the partial reasoning when the turn errors.
7052    ///
7053    /// A caller debugging a truncated thinking turn wants to see how far the
7054    /// model got, so the reasoning must not vanish: the history push runs on
7055    /// `is_empty_assistant_turn` (false for a reasoning-only turn) *before* the
7056    /// truncation guard, so the turn is recorded and then the error is raised.
7057    /// This pins that ordering — swapping the two would trade one invisible
7058    /// failure for another.
7059    #[tokio::test]
7060    async fn reasoning_survives_into_history_when_the_truncated_turn_errors() {
7061        let model = MockCompletionModel::from_stream_turns([[
7062            MockStreamEvent::reasoning("partial thinking worth keeping"),
7063            MockStreamEvent::FinalResponse(
7064                mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
7065            ),
7066        ]]);
7067        let agent = AgentBuilder::new(model).build();
7068
7069        let mut stream = agent.stream_prompt("solve this").await;
7070        let mut streamed_reasoning = String::new();
7071
7072        while let Some(item) = stream.next().await {
7073            match item {
7074                Ok(MultiTurnStreamItem::StreamAssistantItem(
7075                    StreamedAssistantContent::Reasoning { reasoning, .. },
7076                )) => {
7077                    streamed_reasoning.push_str(&reasoning.display_text());
7078                }
7079                Ok(MultiTurnStreamItem::FinalResponse(_)) => {
7080                    panic!("the truncated reasoning-only turn should error")
7081                }
7082                Ok(_) => {}
7083                Err(_) => break,
7084            }
7085        }
7086
7087        assert!(
7088            streamed_reasoning.contains("partial thinking worth keeping"),
7089            "the partial reasoning must reach the consumer before the error, so a \
7090             truncated thinking turn is debuggable — got {streamed_reasoning:?}"
7091        );
7092    }
7093
7094    /// Background task that logs periodically to detect span leakage.
7095    /// If span leakage occurs, these logs will be prefixed with `invoke_agent{...}`.
7096    async fn background_logger(stop: Arc<AtomicBool>, leak_count: Arc<AtomicU32>) {
7097        let mut interval = tokio::time::interval(Duration::from_millis(50));
7098        let mut count = 0u32;
7099
7100        while !stop.load(Ordering::Relaxed) {
7101            interval.tick().await;
7102            count += 1;
7103
7104            tracing::event!(
7105                target: "background_logger",
7106                tracing::Level::INFO,
7107                count = count,
7108                "Background tick"
7109            );
7110
7111            // Check if we're inside an unexpected span
7112            let current = tracing::Span::current();
7113            if !current.is_disabled() && !current.is_none() {
7114                leak_count.fetch_add(1, Ordering::Relaxed);
7115            }
7116        }
7117
7118        tracing::info!(target: "background_logger", total_ticks = count, "Background logger stopped");
7119    }
7120
7121    /// Test that span context doesn't leak to concurrent tasks during streaming.
7122    ///
7123    /// This test verifies that using `.instrument()` instead of `span.enter()` in
7124    /// async_stream prevents thread-local span context from leaking to other tasks.
7125    ///
7126    /// Uses single-threaded runtime to force all tasks onto the same thread,
7127    /// making the span leak deterministic (it only occurs when tasks share a thread).
7128    #[tokio::test(flavor = "current_thread")]
7129    #[ignore = "This requires an API key"]
7130    async fn test_span_context_isolation() -> anyhow::Result<()> {
7131        let stop = Arc::new(AtomicBool::new(false));
7132        let leak_count = Arc::new(AtomicU32::new(0));
7133
7134        // Start background logger
7135        let bg_stop = stop.clone();
7136        let bg_leak = leak_count.clone();
7137        let bg_handle = tokio::spawn(async move {
7138            background_logger(bg_stop, bg_leak).await;
7139        });
7140
7141        // Small delay to let background logger start
7142        tokio::time::sleep(Duration::from_millis(100)).await;
7143
7144        // Make streaming request WITHOUT an outer span so rig creates its own invoke_agent span
7145        // (rig reuses current span if one exists, so we need to ensure there's no current span)
7146        let client = anthropic::Client::from_env()?;
7147        let agent = client
7148            .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
7149            .preamble("You are a helpful assistant.")
7150            .temperature(0.1)
7151            .max_tokens(100)
7152            .build();
7153
7154        let mut stream = agent
7155            .stream_prompt("Say 'hello world' and nothing else.")
7156            .await;
7157
7158        let mut full_content = String::new();
7159        while let Some(item) = stream.next().await {
7160            match item {
7161                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
7162                    text,
7163                ))) => {
7164                    full_content.push_str(&text.text);
7165                }
7166                Ok(MultiTurnStreamItem::FinalResponse(_)) => {
7167                    break;
7168                }
7169                Err(e) => {
7170                    tracing::warn!("Error: {:?}", e);
7171                    break;
7172                }
7173                _ => {}
7174            }
7175        }
7176
7177        tracing::info!("Got response: {:?}", full_content);
7178
7179        // Stop background logger
7180        stop.store(true, Ordering::Relaxed);
7181        bg_handle.await?;
7182
7183        let leaks = leak_count.load(Ordering::Relaxed);
7184        anyhow::ensure!(
7185            leaks == 0,
7186            "SPAN LEAK DETECTED: Background logger was inside unexpected spans {leaks} times. \
7187             This indicates that span.enter() is being used inside async_stream instead of .instrument()"
7188        );
7189
7190        Ok(())
7191    }
7192
7193    /// Test that FinalResponse contains the updated chat history when a starting
7194    /// history is provided via `.history(..)`.
7195    ///
7196    /// This verifies that:
7197    /// 1. PromptResponse.messages() returns Some when a starting history was provided
7198    /// 2. The history contains both the user prompt and assistant response
7199    #[tokio::test]
7200    #[ignore = "This requires an API key"]
7201    async fn test_chat_history_in_final_response() -> anyhow::Result<()> {
7202        use rig_core::message::Message;
7203
7204        let client = anthropic::Client::from_env()?;
7205        let agent = client
7206            .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
7207            .preamble("You are a helpful assistant. Keep responses brief.")
7208            .temperature(0.1)
7209            .max_tokens(50)
7210            .build();
7211
7212        // Send streaming request with history
7213        let empty_history: &[Message] = &[];
7214        let mut stream = agent
7215            .stream_prompt("Say 'hello' and nothing else.")
7216            .history(empty_history)
7217            .await;
7218
7219        // Consume the stream and collect FinalResponse
7220        let mut response_text = String::new();
7221        let mut final_history = None;
7222        while let Some(item) = stream.next().await {
7223            match item {
7224                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
7225                    text,
7226                ))) => {
7227                    response_text.push_str(&text.text);
7228                }
7229                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7230                    final_history = res.messages().map(|h| h.to_vec());
7231                    break;
7232                }
7233                Err(e) => {
7234                    return Err(e.into());
7235                }
7236                _ => {}
7237            }
7238        }
7239
7240        let history = final_history
7241            .ok_or_else(|| anyhow::anyhow!("final response should include history"))?;
7242
7243        // Should contain at least the user message
7244        anyhow::ensure!(
7245            history.iter().any(|m| matches!(m, Message::User { .. })),
7246            "History should contain the user message"
7247        );
7248
7249        // Should contain the assistant response
7250        anyhow::ensure!(
7251            history
7252                .iter()
7253                .any(|m| matches!(m, Message::Assistant { .. })),
7254            "History should contain the assistant response"
7255        );
7256
7257        tracing::info!(
7258            "History after streaming: {} messages, response: {:?}",
7259            history.len(),
7260            response_text
7261        );
7262
7263        Ok(())
7264    }
7265
7266    #[tokio::test]
7267    async fn streaming_appends_to_memory_after_final_response() {
7268        use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7269
7270        let memory = InMemoryConversationMemory::new();
7271        let agent = AgentBuilder::new(streaming_text_then_final_model())
7272            .memory(memory.clone())
7273            .build();
7274
7275        let mut stream = agent
7276            .stream_prompt("hi there")
7277            .conversation("stream-thread")
7278            .await;
7279
7280        let mut history_in_final = None;
7281        while let Some(item) = stream.next().await {
7282            match item {
7283                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7284                    history_in_final = res.messages().map(|h| h.to_vec());
7285                    break;
7286                }
7287                Ok(_) => {}
7288                Err(err) => panic!("unexpected streaming error: {err:?}"),
7289            }
7290        }
7291
7292        let final_history = history_in_final
7293            .expect("PromptResponse.messages should be populated when memory is configured");
7294        assert_eq!(
7295            final_history.len(),
7296            2,
7297            "user prompt + assistant response in final history: {final_history:?}"
7298        );
7299
7300        let stored = memory.load("stream-thread").await.unwrap();
7301        assert_eq!(stored.len(), 2, "memory should contain user + assistant");
7302    }
7303
7304    #[tokio::test]
7305    async fn streaming_reasoning_without_tools_does_not_duplicate_final_history() {
7306        let agent = AgentBuilder::new(MockCompletionModel::from_stream_turns([[
7307            MockStreamEvent::text("final answer"),
7308            MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
7309            MockStreamEvent::final_response_with_total_tokens(3),
7310        ]]))
7311        .build();
7312
7313        let mut stream = agent
7314            .stream_prompt("think before answering")
7315            .history(Vec::<Message>::new())
7316            .await;
7317
7318        let mut history_in_final = None;
7319        while let Some(item) = stream.next().await {
7320            match item {
7321                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7322                    history_in_final = res.messages().map(|h| h.to_vec());
7323                    break;
7324                }
7325                Ok(_) => {}
7326                Err(err) => panic!("unexpected streaming error: {err:?}"),
7327            }
7328        }
7329
7330        let final_history = history_in_final
7331            .expect("PromptResponse.messages should be populated when with_history is used");
7332        assert_eq!(
7333            final_history.len(),
7334            2,
7335            "user prompt + one assistant response in final history: {final_history:?}"
7336        );
7337
7338        assert!(matches!(
7339            final_history.first(),
7340            Some(Message::User { content })
7341                if matches!(
7342                    content.first(),
7343                    Some(UserContent::Text(text)) if text.text == "think before answering"
7344                )
7345        ));
7346
7347        let assistant_messages = final_history
7348            .iter()
7349            .filter_map(|message| match message {
7350                Message::Assistant { content, .. } => Some(content),
7351                _ => None,
7352            })
7353            .collect::<Vec<_>>();
7354        assert_eq!(
7355            assistant_messages.len(),
7356            1,
7357            "reasoning turn should produce exactly one assistant history message: {final_history:?}"
7358        );
7359        let assistant_content = assistant_messages
7360            .first()
7361            .expect("expected assistant history message");
7362        assert!(assistant_content.iter().any(|item| matches!(
7363            item,
7364            AssistantContent::Text(text) if text.text == "final answer"
7365        )));
7366        assert!(assistant_content.iter().any(|item| matches!(
7367            item,
7368            AssistantContent::Reasoning(reasoning)
7369                if reasoning.id.as_deref() == Some("rs_1")
7370                    && reasoning.content.iter().any(|content| matches!(
7371                        content,
7372                        ReasoningContent::Text { text, .. } if text == "reasoned step"
7373                    ))
7374        )));
7375        let reasoning_index = assistant_content
7376            .iter()
7377            .position(|item| matches!(item, AssistantContent::Reasoning(_)))
7378            .expect("assistant history should contain reasoning");
7379        let text_index = assistant_content
7380            .iter()
7381            .position(|item| matches!(item, AssistantContent::Text(_)))
7382            .expect("assistant history should contain text");
7383        assert!(
7384            reasoning_index < text_index,
7385            "assistant reasoning must be stored before assistant text: {assistant_content:?}"
7386        );
7387    }
7388
7389    #[tokio::test]
7390    async fn streaming_with_history_overrides_memory() {
7391        use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7392
7393        let memory = InMemoryConversationMemory::new();
7394        memory
7395            .append("t1", vec![Message::user("from-memory")])
7396            .await
7397            .unwrap();
7398
7399        let agent = AgentBuilder::new(streaming_text_then_final_model())
7400            .memory(memory.clone())
7401            .build();
7402
7403        let mut stream = agent
7404            .stream_prompt("hi")
7405            .conversation("t1")
7406            .history(vec![Message::user("from-caller")])
7407            .await;
7408
7409        while let Some(item) = stream.next().await {
7410            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7411                break;
7412            }
7413        }
7414
7415        let stored = memory.load("t1").await.unwrap();
7416        assert_eq!(
7417            stored.len(),
7418            1,
7419            "with_history bypasses memory; only the pre-seeded entry remains: {stored:?}"
7420        );
7421    }
7422
7423    #[tokio::test]
7424    async fn streaming_without_memory_disables_for_request() {
7425        use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7426
7427        let memory = InMemoryConversationMemory::new();
7428        let agent = AgentBuilder::new(streaming_text_then_final_model())
7429            .memory(memory.clone())
7430            .conversation("default")
7431            .build();
7432
7433        let mut stream = agent.stream_prompt("hi").without_memory().await;
7434
7435        while let Some(item) = stream.next().await {
7436            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7437                break;
7438            }
7439        }
7440
7441        let stored = memory.load("default").await.unwrap();
7442        assert!(stored.is_empty(), "without_memory disables save");
7443    }
7444
7445    #[tokio::test]
7446    async fn streaming_load_error_yields_memory_error() {
7447        let agent = AgentBuilder::new(streaming_text_then_final_model())
7448            .memory(FailingMemory::default())
7449            .build();
7450
7451        let mut stream = agent.stream_prompt("hi").conversation("t1").await;
7452
7453        let first = stream.next().await.expect("at least one item");
7454        match first {
7455            Err(StreamingError::Prompt(err)) => match *err {
7456                PromptError::MemoryError(err) => {
7457                    assert!(err.to_string().contains("load boom"));
7458                }
7459                other => panic!("expected PromptError::MemoryError, got {other:?}"),
7460            },
7461            other => panic!("expected StreamingError::Prompt, got {other:?}"),
7462        }
7463    }
7464
7465    #[tokio::test]
7466    async fn streaming_with_filter_shapes_loaded_history() {
7467        use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7468
7469        let memory = InMemoryConversationMemory::new()
7470            .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
7471        memory
7472            .append(
7473                "t1",
7474                vec![
7475                    Message::user("1"),
7476                    Message::assistant("2"),
7477                    Message::user("3"),
7478                    Message::assistant("4"),
7479                ],
7480            )
7481            .await
7482            .unwrap();
7483
7484        let model = MockCompletionModel::from_stream_turns([[
7485            MockStreamEvent::text("ok"),
7486            MockStreamEvent::final_response_with_total_tokens(1),
7487        ]]);
7488        let recorded = model.clone();
7489        let agent = AgentBuilder::new(model).memory(memory).build();
7490
7491        let mut stream = agent.stream_prompt("ping").conversation("t1").await;
7492        while let Some(item) = stream.next().await {
7493            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7494                break;
7495            }
7496        }
7497
7498        let received = recorded.requests()[0].chat_history.clone();
7499        assert_eq!(
7500            received.len(),
7501            3,
7502            "window-truncated history (2) + current prompt: {received:?}"
7503        );
7504    }
7505
7506    #[tokio::test]
7507    async fn streaming_append_error_does_not_suppress_final_response() {
7508        let agent = AgentBuilder::new(streaming_text_then_final_model())
7509            .memory(AppendFailingMemory::default())
7510            .build();
7511
7512        let mut stream = agent.stream_prompt("hi").conversation("t1").await;
7513
7514        let mut saw_final = false;
7515        while let Some(item) = stream.next().await {
7516            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7517                saw_final = true;
7518                break;
7519            }
7520        }
7521        assert!(
7522            saw_final,
7523            "FinalResponse must be yielded even when memory.append fails"
7524        );
7525    }
7526}