Skip to main content

rig_core/agent/prompt_request/
streaming.rs

1use crate::{
2    OneOrMany,
3    agent::completion::{PreparedCompletionRequest, build_prepared_completion_request},
4    agent::hook::{
5        AgentHook, HookContext, HookStack, InvalidToolCallHookAction, StepEvent, StepEventKind,
6    },
7    agent::prompt_request::{assistant_text_from_choice, is_empty_assistant_turn},
8    agent::run::{
9        AgentRun, AgentRunStep, PendingToolCall,
10        streamed::{StreamedResolution, StreamedTurnAssembler, StreamedTurnEvent},
11    },
12    agent::runner::{
13        AgentRunner, CompletionCallOutcome, InvalidDecision, ToolExecution, acquire_agent_span,
14        append_run_messages, build_chat_span, flow_into_invalid, new_execute_tool_span,
15        observe_flow, resolve_completion_call, run_single_tool,
16    },
17    completion::GetTokenUsage,
18    message::{AssistantContent, UserContent},
19    streaming::{StreamedAssistantContent, StreamedUserContent, ToolCallDeltaContent},
20    tool::ToolCallExtensions,
21    wasm_compat::{WasmBoxedFuture, WasmCompatSend},
22};
23use futures::{Stream, StreamExt, stream};
24use serde::{Deserialize, Serialize};
25use std::{collections::VecDeque, pin::Pin, sync::Arc};
26use tracing_futures::Instrument;
27
28use super::{CompletionCall, PromptResponse, forward_prompt_setters};
29use crate::{
30    agent::Agent,
31    completion::{CompletionError, CompletionModel, PromptError},
32    message::{Message, Text},
33    tool::ToolSetError,
34};
35
36#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
37pub type StreamingResult<R> =
38    Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + Send>>;
39
40#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
41pub type StreamingResult<R> =
42    Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>>>>;
43
44#[derive(Deserialize, Serialize, Debug, Clone)]
45#[serde(tag = "type", rename_all = "camelCase")]
46#[non_exhaustive]
47pub enum MultiTurnStreamItem<R> {
48    /// A streamed assistant content item — the content the **model emitted**:
49    /// text/reasoning deltas, tool-call deltas, and, when the model turn is
50    /// committed, the complete [`StreamedAssistantContent::ToolCall`] for each
51    /// tool call Rig routes to execution. Such a call is reported here whether or
52    /// not the tool body ultimately runs (a hook `Flow::Skip` still reports it);
53    /// it is **not** an execution-lifecycle event (see
54    /// [`ToolExecutionStart`](Self::ToolExecutionStart)).
55    ///
56    /// Two kinds of model tool call are **not** re-emitted as a complete
57    /// `ToolCall` item here (their arguments still stream as tool-call deltas):
58    /// a call rejected and handled by invalid-tool-call recovery (surfaced via
59    /// that recovery path), and a structured-output Tool-mode output-tool call,
60    /// which finalizes the run directly — its structured result is surfaced in
61    /// the [`FinalResponse`](Self::FinalResponse) rather than as a completed
62    /// `ToolCall` item.
63    StreamAssistantItem(StreamedAssistantContent<R>),
64    /// Rig **executed** a tool call. Surfaced only for a tool whose body actually
65    /// ran (it passed its `ToolCall` hook checks) — never for a model tool call
66    /// that was dropped by a sibling's termination, skipped by a hook
67    /// (`Flow::Skip`), or resolved by invalid-tool-call recovery. The tool batch
68    /// commits and surfaces **atomically at every `tool_concurrency`** (including
69    /// the sequential default): this event is surfaced together with its
70    /// `ToolResult` once the whole batch has settled successfully, so a run that
71    /// terminates mid-batch produces **no** `ToolExecutionStart` (hence no orphan
72    /// start without a result). Correlate with the model tool call and the result
73    /// via `internal_call_id`.
74    ToolExecutionStart {
75        /// The tool call as **executed**: the model's call with any
76        /// [`Flow::RewriteArgs`](crate::agent::Flow::RewriteArgs) hook rewrite
77        /// applied (so a redaction rewrite is reflected here, not leaked). The
78        /// model's *original* call is reported via
79        /// [`StreamAssistantItem`](Self::StreamAssistantItem).
80        tool_call: crate::message::ToolCall,
81        /// Rig-generated id correlating this execution with the model tool call
82        /// ([`StreamedAssistantContent::ToolCall::internal_call_id`]) and the
83        /// resulting [`StreamedUserContent::ToolResult`].
84        internal_call_id: String,
85    },
86    /// A streamed user content item: the **result** of an executed (or
87    /// hook-skipped) tool call. The tool batch commits and surfaces atomically at
88    /// every `tool_concurrency` (including the sequential default): results are
89    /// surfaced (in call order) only after the whole batch settles successfully —
90    /// a run that terminates mid-batch surfaces no successful tool results.
91    StreamUserItem(StreamedUserContent),
92    /// Details for one successfully completed completion request made by this agent stream.
93    ///
94    /// This is emitted when a provider call finishes. Usage is the provider's
95    /// final usage for that completion request when available; it is not
96    /// incremental per streamed token.
97    ///
98    /// ```rust,ignore
99    /// match item {
100    ///     MultiTurnStreamItem::CompletionCall(completion_call) => {
101    ///         // Zero-valued usage means the provider reported no metrics.
102    ///         if completion_call.usage.has_values() {
103    ///             let context_tokens = completion_call.usage.input_tokens;
104    ///         }
105    ///     }
106    ///     _ => {}
107    /// }
108    /// ```
109    CompletionCall(CompletionCall),
110    /// The final result from the stream: the unified [`PromptResponse`] shared
111    /// with the blocking surface.
112    FinalResponse(PromptResponse),
113}
114
115/// Build the unified [`PromptResponse`] for the streaming surface from the
116/// final turn's structured content.
117fn final_response_from_content(
118    content: OneOrMany<AssistantContent>,
119    aggregated_usage: crate::completion::Usage,
120    completion_calls: Vec<CompletionCall>,
121    history: Option<Vec<Message>>,
122) -> PromptResponse {
123    let mut response = PromptResponse::new(assistant_text_from_choice(&content), aggregated_usage)
124        .with_content(content)
125        .with_completion_calls(completion_calls);
126    response.messages = history;
127    response
128}
129
130impl<R> MultiTurnStreamItem<R> {
131    pub(crate) fn stream_item(item: StreamedAssistantContent<R>) -> Self {
132        Self::StreamAssistantItem(item)
133    }
134
135    pub fn final_response(
136        content: OneOrMany<AssistantContent>,
137        aggregated_usage: crate::completion::Usage,
138    ) -> Self {
139        Self::FinalResponse(final_response_from_content(
140            content,
141            aggregated_usage,
142            Vec::new(),
143            None,
144        ))
145    }
146
147    pub fn final_response_with_history(
148        content: OneOrMany<AssistantContent>,
149        aggregated_usage: crate::completion::Usage,
150        history: Option<Vec<Message>>,
151    ) -> Self {
152        Self::FinalResponse(final_response_from_content(
153            content,
154            aggregated_usage,
155            Vec::new(),
156            history,
157        ))
158    }
159
160    pub(crate) fn final_response_with_completion_calls(
161        content: OneOrMany<AssistantContent>,
162        aggregated_usage: crate::completion::Usage,
163        completion_calls: Vec<CompletionCall>,
164        history: Option<Vec<Message>>,
165    ) -> Self {
166        Self::FinalResponse(final_response_from_content(
167            content,
168            aggregated_usage,
169            completion_calls,
170            history,
171        ))
172    }
173}
174
175/// Drain a provider stream abandoned by invalid tool-call recovery so the
176/// reported usage for the recovered completion call is not lost.
177async fn drain_stream_usage<R>(
178    stream: &mut crate::streaming::StreamingCompletionResponse<R>,
179) -> Result<crate::completion::Usage, StreamingError>
180where
181    R: Clone + Unpin + GetTokenUsage,
182{
183    while let Some(content) = stream.next().await {
184        match content {
185            Ok(StreamedAssistantContent::Final(final_resp)) => {
186                return Ok(final_resp.token_usage());
187            }
188            Ok(_) => {}
189            Err(err) => return Err(err.into()),
190        }
191    }
192
193    Ok(crate::completion::Usage::new())
194}
195
196pub(crate) fn record_usage_on_span(span: &tracing::Span, usage: crate::completion::Usage) {
197    span.record("gen_ai.usage.input_tokens", usage.input_tokens);
198    span.record("gen_ai.usage.output_tokens", usage.output_tokens);
199    span.record(
200        "gen_ai.usage.cache_read.input_tokens",
201        usage.cached_input_tokens,
202    );
203    span.record(
204        "gen_ai.usage.cache_creation.input_tokens",
205        usage.cache_creation_input_tokens,
206    );
207    span.record(
208        "gen_ai.usage.tool_use_prompt_tokens",
209        usage.tool_use_prompt_tokens,
210    );
211    span.record("gen_ai.usage.reasoning_tokens", usage.reasoning_tokens);
212}
213
214/// Build the final streamed content for a finished run (#1928).
215///
216/// When the finishing turn carries a tool call it is a Tool-mode output-tool
217/// call (a real tool call would have routed to `CallTools`, not `Done`). In that
218/// case the tool call AND the model's prose are dropped, any reasoning/image
219/// content is kept, and `output` is appended as the final text — so the streamed
220/// [`PromptResponse::output`] string is the structured output rather than the
221/// prose, with no unanswered tool_use, matching the non-streaming `output`. Note
222/// this shapes only the surfaced [`PromptResponse::content`]; the persisted
223/// message history is built by the state machine (which keeps the prose, like the
224/// blocking driver), so `content` and `messages` intentionally differ on prose in
225/// this case.
226/// Otherwise returns `None` and the caller surfaces the turn's content unchanged.
227fn finalize_streamed_choice(
228    last_final_choice: &OneOrMany<AssistantContent>,
229    output: &str,
230) -> Option<OneOrMany<AssistantContent>> {
231    let finalized_via_output_tool = last_final_choice
232        .iter()
233        .any(|item| matches!(item, AssistantContent::ToolCall(_)));
234    if !finalized_via_output_tool {
235        return None;
236    }
237    let mut items: Vec<AssistantContent> = last_final_choice
238        .iter()
239        .filter(|item| {
240            !matches!(
241                item,
242                AssistantContent::ToolCall(_) | AssistantContent::Text(_)
243            )
244        })
245        .cloned()
246        .collect();
247    items.push(AssistantContent::text(output.to_string()));
248    Some(
249        OneOrMany::from_iter_optional(items)
250            .unwrap_or_else(|| OneOrMany::one(AssistantContent::text(output.to_string()))),
251    )
252}
253
254#[derive(Debug, thiserror::Error)]
255pub enum StreamingError {
256    #[error("CompletionError: {0}")]
257    Completion(#[from] CompletionError),
258    #[error("PromptError: {0}")]
259    Prompt(#[from] Box<PromptError>),
260    #[error("ToolSetError: {0}")]
261    Tool(#[from] ToolSetError),
262}
263
264/// Surface [`crate::memory::ConversationMemory`] failures through the existing
265/// [`CompletionError::RequestError`] variant so adding memory support does not
266/// require a new top-level [`StreamingError`] arm.
267impl From<crate::memory::MemoryError> for StreamingError {
268    fn from(err: crate::memory::MemoryError) -> Self {
269        Self::Completion(CompletionError::RequestError(Box::new(err)))
270    }
271}
272
273/// A builder for creating prompt requests with customizable options.
274/// Uses generics to track which options have been set during the build process.
275///
276/// When the agent has no configured `default_max_turns`, the implicit budget is
277/// one model call. Use [`.max_turns()`](Self::max_turns) to override the agent's
278/// configured or implicit budget; a tool call followed by a model-authored final
279/// answer generally requires at least two model calls.
280pub struct StreamingPromptRequest<M>
281where
282    M: CompletionModel,
283{
284    /// The hook-aware driver this streaming request configures and runs.
285    runner: AgentRunner<M>,
286}
287
288impl<M> StreamingPromptRequest<M>
289where
290    M: CompletionModel + 'static,
291    <M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
292{
293    /// Create a new StreamingPromptRequest from an agent WITHOUT the agent's
294    /// default hooks. Use [`from_agent`](Self::from_agent) to include them.
295    pub fn new(agent: Arc<Agent<M>>, prompt: impl Into<Message>) -> StreamingPromptRequest<M> {
296        let mut runner = AgentRunner::from_agent(agent.as_ref(), prompt);
297        runner.hooks = HookStack::new();
298        StreamingPromptRequest { runner }
299    }
300
301    /// Create a new StreamingPromptRequest from an agent, cloning the agent's
302    /// data and default hook stack.
303    pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> StreamingPromptRequest<M> {
304        StreamingPromptRequest {
305            runner: AgentRunner::from_agent(agent, prompt),
306        }
307    }
308
309    /// Set the total model-call budget, including the initial call and every
310    /// retry or continuation. Zero emits no model calls; one permits only the
311    /// initial call.
312    ///
313    /// Named to match the blocking
314    /// [`PromptRequest::max_turns`](super::PromptRequest::max_turns) and
315    /// [`TypedPromptRequest::max_turns`](super::TypedPromptRequest::max_turns)
316    /// builders so the same call reads identically on either surface.
317    pub fn max_turns(mut self, turns: usize) -> Self {
318        self.runner = self.runner.max_turns(turns);
319        self
320    }
321
322    /// Execute up to `concurrency` of a turn's tool calls at once (1 by default,
323    /// i.e. sequential). See [`AgentRunner::tool_concurrency`]: at any
324    /// `concurrency` the stream emits the model's `ToolCall` items (call order),
325    /// then — atomically, after the whole tool batch settles successfully — the
326    /// per-tool `ToolExecutionStart` + `ToolResult` items in **call order** (not
327    /// completion order). The streamed message history is unchanged at any
328    /// `concurrency`.
329    pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
330        self.runner = self.runner.tool_concurrency(concurrency);
331        self
332    }
333
334    /// Append a hook to this request's hook stack (on top of any the agent
335    /// already carries). Hooks run in registration order; how their results
336    /// compose is event-dependent (`CompletionCall` request patches accumulate
337    /// and merge, `ToolCall`/`ToolResult` rewrites chain, and only
338    /// observe-only/recovery events use first-non-`Continue`-wins). See the
339    /// [`hook`](crate::agent::hook) module docs.
340    pub fn add_hook<H>(mut self, hook: H) -> Self
341    where
342        H: AgentHook<M> + 'static,
343    {
344        self.runner = self.runner.add_hook(hook);
345        self
346    }
347
348    forward_prompt_setters!(runner);
349
350    async fn send(self) -> StreamingResult<M::StreamingResponse> {
351        self.runner.stream().await
352    }
353}
354
355/// A boxed, medium-specific item stream for one engine step (model turn or tool
356/// batch). Boxed so a generic [`drive_agent`] can forward it without the
357/// per-step future leaking into the engine's own (`Send`) inference.
358#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
359pub(crate) type DriveStream<'a, R> =
360    Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + Send + 'a>>;
361
362#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
363pub(crate) type DriveStream<'a, R> =
364    Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + 'a>>;
365
366/// One item emitted by the shared engine [`drive_agent`].
367///
368/// `Item`s are forwarded to a streaming consumer (and ignored by the blocking
369/// fold); `Done` carries both the canonical [`PromptResponse`] the blocking
370/// surface returns and the medium-specific final stream item the streaming
371/// surface yields.
372// The large `Item` variant is the per-delta hot path (one per streamed token);
373// boxing it to shrink the variant spread would add an allocation per delta,
374// which the streaming path is specifically tuned to avoid. `Done` is yielded
375// once per run, so the wasted space on that rare variant is irrelevant.
376#[allow(clippy::large_enum_variant)]
377pub(crate) enum DriveItem<R> {
378    /// An intermediate stream item (assistant delta, tool call/result, a
379    /// per-call `CompletionCall`, or — last, for the streaming surface — the
380    /// final response item).
381    Item(MultiTurnStreamItem<R>),
382    /// The run finished; carries the canonical response the blocking fold
383    /// returns. The streaming surface has already received the final item as the
384    /// preceding `Item` and ignores this.
385    Done(Box<PromptResponse>),
386}
387
388/// The per-medium half of the agent loop: how a turn is fetched from the model,
389/// how its tools are executed, and how the run's spans/usage/final item are
390/// shaped. The medium-independent outer loop (turn counting, the `CompletionCall`
391/// hook, request preparation, memory) lives once in [`drive_agent`]; only the
392/// genuinely divergent pieces are behind this trait. Invalid-tool-call recovery
393/// is one of them — it lives inside each source's `run_model_turn` (end-of-turn
394/// for blocking, mid-stream for streaming), not in `drive_agent`.
395pub(crate) trait TurnSource<M>: WasmCompatSend
396where
397    M: CompletionModel,
398{
399    /// The raw provider response carried on per-delta stream items.
400    type Raw: WasmCompatSend;
401
402    /// Build this medium's per-turn `chat` span (name + parenting + any
403    /// `follows_from` chaining differ between blocking and streaming).
404    fn open_chat_span(
405        &self,
406        runner: &AgentRunner<M>,
407        effective_preamble: Option<&str>,
408    ) -> tracing::Span;
409
410    /// Run one model turn: issue the provider call, feed the result into the
411    /// sans-IO machine, and yield any intermediate items. Returning normally
412    /// advances the loop; yielding an `Err` terminates the run.
413    #[allow(clippy::too_many_arguments)]
414    fn run_model_turn<'a>(
415        &'a mut self,
416        runner: &'a AgentRunner<M>,
417        hook_ctx: &'a HookContext,
418        run: &'a mut AgentRun,
419        prepared: PreparedCompletionRequest<M>,
420        chat_span: tracing::Span,
421        agent_span: &'a tracing::Span,
422        prompt: Message,
423    ) -> DriveStream<'a, Self::Raw>;
424
425    /// Execute a turn's tool calls, feeding the results into the machine and
426    /// yielding any intermediate items.
427    fn run_tool_calls<'a>(
428        &'a self,
429        runner: &'a AgentRunner<M>,
430        hook_ctx: &'a HookContext,
431        run: &'a mut AgentRun,
432        calls: Vec<PendingToolCall>,
433    ) -> DriveStream<'a, Self::Raw>;
434
435    /// Record run-level telemetry onto the agent span at `Done`. Gated on
436    /// `created_agent_span` so a caller-supplied outer span is never polluted.
437    fn record_run_level_telemetry(
438        &self,
439        agent_span: &tracing::Span,
440        response: &PromptResponse,
441        created_agent_span: bool,
442    );
443
444    /// Build the final stream item surfaced at `Done`, or `None` when the
445    /// surface discards it (the blocking fold) so the engine skips the work.
446    fn final_item(&self, response: &PromptResponse) -> Option<MultiTurnStreamItem<Self::Raw>>;
447}
448
449/// Convert a [`StreamingError`] back into a [`PromptError`] for the blocking
450/// surface ([`AgentRunner::run`]), which folds the shared engine. Lossless:
451/// every streaming error originates as one of these.
452pub(crate) fn streaming_error_into_prompt(err: StreamingError) -> PromptError {
453    match err {
454        StreamingError::Completion(err) => PromptError::CompletionError(err),
455        StreamingError::Prompt(err) => *err,
456        StreamingError::Tool(err) => PromptError::ToolError(err),
457    }
458}
459
460/// The single agent drive loop, shared by the blocking and streaming surfaces.
461///
462/// Owns the medium-independent loop — `next_step` dispatch, the `CompletionCall`
463/// hook + request preparation, the `Done` memory append — and delegates the
464/// medium-specific model call, tool execution, span shaping and finalization to
465/// a [`TurnSource`]. The streaming surface forwards the yielded [`DriveItem`]s;
466/// the blocking surface folds them to `Done`.
467pub(crate) fn drive_agent<M, S>(
468    runner: AgentRunner<M>,
469    mut source: S,
470    mut run: AgentRun,
471    agent_span: tracing::Span,
472    created_agent_span: bool,
473    memory_handle: Option<(Arc<dyn crate::memory::ConversationMemory>, String)>,
474    is_streaming: bool,
475) -> impl Stream<Item = Result<DriveItem<S::Raw>, StreamingError>>
476where
477    M: CompletionModel,
478    S: TurnSource<M>,
479{
480    async_stream::stream! {
481        // Run-scoped hook context: minted once, shared by every hook event on
482        // both surfaces. `is_streaming` records which surface is driving; the
483        // per-turn index is advanced on each `CallModel` step below.
484        let hook_ctx = HookContext::new(is_streaming, runner.agent_name.clone());
485
486        'outer: loop {
487            let step = match run.next_step() {
488                Ok(step) => step,
489                Err(err) => {
490                    yield Err(Box::new(err).into());
491                    break 'outer;
492                }
493            };
494
495            match step {
496                AgentRunStep::CallModel { prompt, history, turn } => {
497                    if runner.max_turns > 1 {
498                        tracing::info!("Current conversation Turns: {}/{}", turn, runner.max_turns);
499                    }
500                    hook_ctx.set_turn(turn);
501
502                    let request_patch =
503                        match resolve_completion_call(&runner.hooks, &hook_ctx, &prompt, &history, turn).await {
504                            CompletionCallOutcome::Terminate(reason) => {
505                                yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
506                                break 'outer;
507                            }
508                            CompletionCallOutcome::Proceed(request_patch) => request_patch,
509                        };
510
511                    // Record this turn's base system prompt — the patched-or-baseline
512                    // preamble, before any output-mode augmentation the request builder
513                    // appends. Borrow rather than clone since it only needs to outlive
514                    // span creation.
515                    let effective_preamble = request_patch
516                        .as_ref()
517                        .and_then(|o| o.preamble.as_deref())
518                        .or(runner.preamble.as_deref());
519
520                    let chat_span = source.open_chat_span(&runner, effective_preamble);
521
522                    // Pin Tool output mode once committed so later turns stay
523                    // consistent even if the per-turn tool set changes (#1928).
524                    let committed_output_tool = run.output_tool_name().map(str::to_owned);
525                    let prepared = match build_prepared_completion_request(
526                        &runner.model,
527                        prompt.clone(),
528                        &history,
529                        runner.preamble.as_deref(),
530                        &runner.static_context,
531                        runner.temperature,
532                        runner.max_tokens,
533                        runner.additional_params.as_ref(),
534                        runner.tool_choice.as_ref(),
535                        &runner.tool_server_handle,
536                        &runner.dynamic_context,
537                        runner.output_schema.as_ref(),
538                        &runner.output_mode,
539                        committed_output_tool.as_deref(),
540                        request_patch.as_ref(),
541                    )
542                    .await
543                    {
544                        Ok(prepared) => prepared,
545                        Err(err) => {
546                            yield Err(err.into());
547                            break 'outer;
548                        }
549                    };
550                    run.set_output_tool_name(prepared.output_tool_name.clone());
551
552                    let mut turn_stream = source.run_model_turn(
553                        &runner,
554                        &hook_ctx,
555                        &mut run,
556                        prepared,
557                        chat_span,
558                        &agent_span,
559                        prompt,
560                    );
561                    let mut errored = false;
562                    while let Some(item) = turn_stream.next().await {
563                        match item {
564                            Ok(item) => yield Ok(DriveItem::Item(item)),
565                            Err(err) => {
566                                errored = true;
567                                yield Err(err);
568                                break;
569                            }
570                        }
571                    }
572                    drop(turn_stream);
573                    if errored {
574                        break 'outer;
575                    }
576                }
577                AgentRunStep::CallTools { calls } => {
578                    let mut tool_stream = source.run_tool_calls(&runner, &hook_ctx, &mut run, calls);
579                    let mut errored = false;
580                    while let Some(item) = tool_stream.next().await {
581                        match item {
582                            Ok(item) => yield Ok(DriveItem::Item(item)),
583                            Err(err) => {
584                                errored = true;
585                                yield Err(err);
586                                break;
587                            }
588                        }
589                    }
590                    drop(tool_stream);
591                    if errored {
592                        break 'outer;
593                    }
594                }
595                AgentRunStep::Done(response) => {
596                    // Run-completion marker, unifying the blocking and streaming
597                    // drivers' run-finished logs into one shared event.
598                    tracing::info!(
599                        turn = run.turn(),
600                        max_turns = runner.max_turns,
601                        "Agent run finished"
602                    );
603                    source.record_run_level_telemetry(&agent_span, &response, created_agent_span);
604                    append_run_messages(
605                        memory_handle.as_ref(),
606                        response.messages.as_deref().unwrap_or_default(),
607                    )
608                    .await;
609                    // Build the final item only when the surface forwards it
610                    // (streaming). The blocking fold discards it, so its source
611                    // returns `None` and the extra full-response clone is skipped.
612                    if let Some(final_item) = source.final_item(&response) {
613                        yield Ok(DriveItem::Item(final_item));
614                    }
615                    yield Ok(DriveItem::Done(Box::new(response)));
616                    break 'outer;
617                }
618            }
619        }
620    }
621}
622
623/// Execute a turn's tool calls **atomically per batch**, shared by both surfaces.
624///
625/// The batch commits and surfaces all-or-nothing:
626///
627/// - The model tool-call events ([`StreamedAssistantContent::ToolCall`]) are
628///   emitted up front — they report what the model emitted at turn commit.
629/// - Every tool then runs (sequentially at `tool_concurrency <= 1`, else
630///   concurrently bounded by it), with outcomes **collected, not surfaced**.
631/// - On the first hook termination / fail-closed error the batch fails fast: no
632///   new tool starts, not-yet-started concurrent siblings are dropped,
633///   already-started ones are drained, and the deterministic lowest call-index
634///   error is surfaced with **no** successful [`ToolExecutionStart`] /
635///   [`StreamUserItem`](MultiTurnStreamItem::StreamUserItem) items and **no**
636///   history commit.
637/// - Only if the whole batch settles successfully are the per-tool
638///   [`ToolExecutionStart`](MultiTurnStreamItem::ToolExecutionStart) + result
639///   items surfaced (in call order, only for tools whose body actually ran) and
640///   the results committed to run history.
641///
642/// When `forward_items` is `false` (the blocking fold) no stream items are built,
643/// but the collect/commit and fail-fast behavior is identical, so `run()` and
644/// `stream()` return the same terminal reason. `chain_tool_span` lets the
645/// blocking surface chain spans into its linear `follows_from` sequence.
646pub(crate) fn drive_tool_calls<'a, M, R, F>(
647    runner: &'a AgentRunner<M>,
648    hook_ctx: &'a HookContext,
649    run: &'a mut AgentRun,
650    calls: Vec<PendingToolCall>,
651    chain_tool_span: F,
652    forward_items: bool,
653) -> DriveStream<'a, R>
654where
655    M: CompletionModel,
656    R: WasmCompatSend + 'a,
657    F: Fn(tracing::Span) -> tracing::Span + WasmCompatSend + 'a,
658{
659    // Per-call working state: a stable internal_call_id and the execute span,
660    // paired with the model's tool call. `span` is `Span::none()` for a
661    // preresolved (invalid-recovery) call, which never executes.
662    struct PreparedToolCall {
663        tool_call: crate::message::ToolCall,
664        preresolved_result: Option<UserContent>,
665        internal_call_id: String,
666        span: tracing::Span,
667    }
668    // How a settled tool call is surfaced on the stream once the batch succeeds:
669    //   - `Executed`: `ToolExecutionStart` (with the effective, hook-rewritten
670    //     call) + the `ToolResult`.
671    //   - `Skipped`: the `ToolResult` only (a `ToolCall` hook returned `Skip`, so
672    //     nothing ran — no execution-start — but the model still sees the result).
673    //   - `Preresolved`: neither (an invalid-recovery result, already surfaced
674    //     during the model turn); committed to history only.
675    enum ToolSurface {
676        // Boxed to keep this enum small next to the empty `Skipped`/`Preresolved`.
677        Executed(Box<crate::message::ToolCall>),
678        Skipped,
679        Preresolved,
680    }
681    // A collected tool outcome, held (not surfaced or committed) until the whole
682    // batch settles.
683    struct CollectedToolResult {
684        content: UserContent,
685        internal_call_id: String,
686        surface: ToolSurface,
687    }
688
689    Box::pin(async_stream::stream! {
690        let full_history_for_errors = run.full_history();
691        let call_count = calls.len();
692
693        // Assign each call a stable internal_call_id and, for calls that will
694        // actually execute, an execute span. Emit the MODEL tool-call events now,
695        // right after the turn committed: these report what the model emitted and
696        // are *not* execution-lifecycle events. A preresolved call emits no model
697        // tool-call event (its synthetic result was already surfaced during the
698        // model turn) and gets no execute span.
699        let mut prepared: Vec<PreparedToolCall> = Vec::with_capacity(call_count);
700        for pending in calls {
701            let internal_call_id = pending.internal_call_id.unwrap_or_else(crate::id::generate);
702            let (span, preresolved_result) = match pending.preresolved_result {
703                Some(result) => (tracing::Span::none(), Some(result)),
704                None => {
705                    if forward_items {
706                        yield Ok(MultiTurnStreamItem::stream_item(
707                            StreamedAssistantContent::ToolCall {
708                                tool_call: pending.tool_call.clone(),
709                                internal_call_id: internal_call_id.clone(),
710                            },
711                        ));
712                    }
713                    (chain_tool_span(new_execute_tool_span()), None)
714                }
715            };
716            prepared.push(PreparedToolCall {
717                tool_call: pending.tool_call,
718                preresolved_result,
719                internal_call_id,
720                span,
721            });
722        }
723
724        // Run all tools, COLLECTING outcomes in call order — nothing is surfaced
725        // or committed until the whole batch settles (atomic per-batch). On the
726        // first hook termination / fail-closed error we stop starting new tools;
727        // already-started ones are drained; the lowest call-index error wins; and
728        // no successful result is surfaced or committed.
729        let mut collected: Vec<Option<CollectedToolResult>> =
730            (0..call_count).map(|_| None).collect();
731        let mut first_error: Option<(usize, PromptError)> = None;
732
733        if runner.concurrency <= 1 {
734            // Sequential: run in call order, fail-fast on the first terminating
735            // error so the remaining tools never start.
736            for (index, call) in prepared.into_iter().enumerate() {
737                let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
738                if let Some(result) = preresolved_result {
739                    if let Some(slot) = collected.get_mut(index) {
740                        *slot = Some(CollectedToolResult {
741                            content: result,
742                            internal_call_id,
743                            surface: ToolSurface::Preresolved,
744                        });
745                    }
746                    continue;
747                }
748                let outcome = run_single_tool(
749                    &runner.hooks,
750                    hook_ctx,
751                    &runner.tool_server_handle,
752                    &runner.tool_extensions,
753                    &tool_call,
754                    &internal_call_id,
755                    &full_history_for_errors,
756                )
757                .instrument(span)
758                .await;
759                match outcome {
760                    Ok(outcome) => {
761                        let surface = match outcome.execution {
762                            ToolExecution::Executed(effective) => ToolSurface::Executed(effective),
763                            ToolExecution::Skipped => ToolSurface::Skipped,
764                        };
765                        if let Some(slot) = collected.get_mut(index) {
766                            *slot = Some(CollectedToolResult {
767                                content: outcome.content,
768                                internal_call_id,
769                                surface,
770                            });
771                        }
772                    }
773                    Err(err) => {
774                        first_error = Some((index, err));
775                        break;
776                    }
777                }
778            }
779        } else {
780            // Concurrent: bounded by `tool_concurrency`. A shared `terminating`
781            // flag makes a not-yet-started sibling skip (its side effect never
782            // runs) once any sibling terminates — avoiding the Semantic-Kernel
783            // fail-open — while already-in-flight siblings are drained so the
784            // lowest call-index terminator wins and no task is left detached.
785            let terminating = Arc::new(std::sync::atomic::AtomicBool::new(false));
786            let unordered = stream::iter(prepared.into_iter().enumerate())
787                .map(|(index, call)| {
788                    let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
789                    let hooks = &runner.hooks;
790                    let tool_server_handle = &runner.tool_server_handle;
791                    let tool_extensions = &runner.tool_extensions;
792                    let full_history_for_errors = &full_history_for_errors;
793                    let terminating = terminating.clone();
794                    async move {
795                        if let Some(result) = preresolved_result {
796                            return (
797                                index,
798                                Some(Ok(CollectedToolResult {
799                                    content: result,
800                                    internal_call_id,
801                                    surface: ToolSurface::Preresolved,
802                                })),
803                            );
804                        }
805                        // `None` marks a dropped (never-started) sibling.
806                        if terminating.load(std::sync::atomic::Ordering::SeqCst) {
807                            return (index, None);
808                        }
809                        let outcome = run_single_tool(
810                            hooks,
811                            hook_ctx,
812                            tool_server_handle,
813                            tool_extensions,
814                            &tool_call,
815                            &internal_call_id,
816                            full_history_for_errors,
817                        )
818                        .await;
819                        let mapped = outcome.map(|o| {
820                            let surface = match o.execution {
821                                ToolExecution::Executed(effective) => {
822                                    ToolSurface::Executed(effective)
823                                }
824                                ToolExecution::Skipped => ToolSurface::Skipped,
825                            };
826                            CollectedToolResult {
827                                content: o.content,
828                                internal_call_id,
829                                surface,
830                            }
831                        });
832                        (index, Some(mapped))
833                    }
834                    .instrument(span)
835                })
836                .buffer_unordered(runner.concurrency);
837            futures::pin_mut!(unordered);
838
839            while let Some((index, outcome)) = unordered.next().await {
840                // A dropped sibling records nothing.
841                let result = match outcome {
842                    Some(result) => result,
843                    None => continue,
844                };
845                match result {
846                    Ok(collected_result) => {
847                        if let Some(slot) = collected.get_mut(index) {
848                            *slot = Some(collected_result);
849                        }
850                    }
851                    Err(err) => {
852                        // Fail-fast: stop starting new siblings; keep draining
853                        // in-flight ones so the lowest call-index terminator wins.
854                        terminating.store(true, std::sync::atomic::Ordering::SeqCst);
855                        if first_error.as_ref().is_none_or(|(i, _)| index < *i) {
856                            first_error = Some((index, err));
857                        }
858                    }
859                }
860            }
861        }
862
863        // Settle. On termination: surface only the deterministic error — no
864        // execution-start, no result, no history commit (all-or-nothing).
865        if let Some((_, err)) = first_error {
866            yield Err(StreamingError::Prompt(Box::new(err)));
867            return;
868        }
869
870        // Success: surface each call's stream items in call order, then commit the
871        // results in call order. An executed call surfaces `ToolExecutionStart`
872        // (with the effective, hook-rewritten call) then its `ToolResult`; a
873        // hook-skipped call surfaces its `ToolResult` only (nothing ran); a
874        // preresolved call surfaces nothing (already surfaced during the model
875        // turn) but is still committed. Every non-dropped slot is filled; a
876        // dropped slot only occurs after a termination, handled above.
877        let mut committed: Vec<UserContent> = Vec::with_capacity(call_count);
878        for slot in collected {
879            let CollectedToolResult { content, internal_call_id, surface } = match slot {
880                Some(collected_result) => collected_result,
881                None => {
882                    yield Err(StreamingError::Prompt(Box::new(PromptError::CompletionError(
883                        CompletionError::ResponseError(
884                            "tool execution finished without producing every result".to_string(),
885                        ),
886                    ))));
887                    return;
888                }
889            };
890            if forward_items {
891                // An executed call also surfaces its execution-start; a skipped
892                // call surfaces only its result; a preresolved call surfaces
893                // nothing here.
894                let surface_result = match surface {
895                    ToolSurface::Executed(tool_call) => {
896                        yield Ok(MultiTurnStreamItem::ToolExecutionStart {
897                            tool_call: *tool_call,
898                            internal_call_id: internal_call_id.clone(),
899                        });
900                        true
901                    }
902                    ToolSurface::Skipped => true,
903                    ToolSurface::Preresolved => false,
904                };
905                if surface_result
906                    && let UserContent::ToolResult(tool_result) = &content
907                {
908                    yield Ok(MultiTurnStreamItem::StreamUserItem(
909                        StreamedUserContent::ToolResult {
910                            tool_result: tool_result.clone(),
911                            internal_call_id,
912                        },
913                    ));
914                }
915            }
916            committed.push(content);
917        }
918
919        if let Err(err) = run.tool_results(committed) {
920            yield Err(Box::new(err).into());
921            return;
922        }
923    })
924}
925
926/// [`TurnSource`] for the streaming surface: each turn opens a provider stream,
927/// drives a [`StreamedTurnAssembler`], and yields assistant/tool deltas.
928pub(crate) struct StreamingTurnSource {
929    /// The raw provider choice of the most recent turn; the final response
930    /// surfaces it as-is, even when canonical reordering was recorded in history.
931    last_final_choice: OneOrMany<AssistantContent>,
932    last_message_id: Option<String>,
933    /// Resolved agent name, kept only for the empty-turn diagnostic warning.
934    agent_name: String,
935    /// Whether we created the agent span (vs. adopting a caller's ambient span);
936    /// gates recording `gen_ai.completion` onto it, matching the blocking source
937    /// so neither surface pollutes a caller-supplied span.
938    created_agent_span: bool,
939    /// Hot-path interest gates, computed once: skip building/dispatching the
940    /// high-frequency delta events when no hook observes them.
941    observes_text_delta: bool,
942    observes_tool_call_delta: bool,
943    /// Whether any hook is present — gates building the (history-cloning)
944    /// invalid-tool diagnostic context.
945    has_hooks: bool,
946}
947
948impl StreamingTurnSource {
949    pub(crate) fn new<M: CompletionModel>(
950        hooks: &HookStack<M>,
951        agent_name: String,
952        created_agent_span: bool,
953    ) -> Self {
954        Self {
955            last_final_choice: OneOrMany::one(AssistantContent::text("")),
956            last_message_id: None,
957            agent_name,
958            created_agent_span,
959            observes_text_delta: hooks.observes(StepEventKind::TextDelta),
960            observes_tool_call_delta: hooks.observes(StepEventKind::ToolCallDelta),
961            has_hooks: !hooks.is_empty(),
962        }
963    }
964}
965
966impl<M> TurnSource<M> for StreamingTurnSource
967where
968    M: CompletionModel,
969    <M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
970{
971    type Raw = M::StreamingResponse;
972
973    fn open_chat_span(
974        &self,
975        runner: &AgentRunner<M>,
976        effective_preamble: Option<&str>,
977    ) -> tracing::Span {
978        build_chat_span!(runner, effective_preamble, "chat_streaming")
979    }
980
981    fn run_model_turn<'a>(
982        &'a mut self,
983        runner: &'a AgentRunner<M>,
984        hook_ctx: &'a HookContext,
985        run: &'a mut AgentRun,
986        prepared: PreparedCompletionRequest<M>,
987        chat_span: tracing::Span,
988        agent_span: &'a tracing::Span,
989        current_prompt: Message,
990    ) -> DriveStream<'a, M::StreamingResponse> {
991        Box::pin(async_stream::stream! {
992            let mut stream = match prepared
993                .builder
994                .stream()
995                .instrument(chat_span.clone())
996                .await
997            {
998                Ok(stream) => stream,
999                Err(err) => {
1000                    yield Err(err.into());
1001                    return;
1002                }
1003            };
1004            // Captured from each completion-call emission so the normalized
1005            // `ModelTurnFinished` event carries the turn's usage.
1006            let mut last_usage = crate::completion::Usage::new();
1007
1008            let mut assembler = StreamedTurnAssembler::new(
1009                prepared.executable_tool_names.clone(),
1010                prepared.allowed_tool_names.clone(),
1011            );
1012            let mut completion_call_emitted = false;
1013            let mut turn_abandoned = false;
1014            // Mirrors the blocking driver's `response_hook_suppressed`: a turn
1015            // whose invalid tool call was repaired is a recovered turn, so its
1016            // response-finish hook is suppressed.
1017            let mut turn_recovered = false;
1018
1019            // Emit the turn's single `CompletionCall` exactly once, recording its
1020            // usage onto the chat span and into the run. Defined here (not a free
1021            // fn) so it captures `completion_call_emitted`/`chat_span`/`run`; the
1022            // `yield` stays at each call site because `async_stream::stream!`
1023            // cannot see a `yield` produced inside a nested macro expansion.
1024            // Returns the item to yield (`Some` the first time, `None` after), or
1025            // the terminal error to surface.
1026            macro_rules! emit_completion_call {
1027                ($usage:expr) => {{
1028                    let usage = $usage;
1029                    last_usage = usage;
1030                    if !completion_call_emitted {
1031                        if usage.has_values() {
1032                            record_usage_on_span(&chat_span, usage);
1033                        }
1034                        match run.record_streamed_completion_call(usage) {
1035                            Ok(call) => {
1036                                completion_call_emitted = true;
1037                                Ok(Some(MultiTurnStreamItem::CompletionCall(call)))
1038                            }
1039                            Err(err) => Err(Box::new(err).into()),
1040                        }
1041                    } else {
1042                        Ok(None)
1043                    }
1044                }};
1045            }
1046
1047            'turn: while let Some(item) = stream.next().await {
1048                let item = match item {
1049                    Ok(item) => item,
1050                    Err(err) => {
1051                        yield Err(err.into());
1052                        return;
1053                    }
1054                };
1055                let mut events: VecDeque<StreamedTurnEvent> = match assembler.ingest(&item) {
1056                    Ok(events) => events.into(),
1057                    Err(err) => {
1058                        yield Err(err.into());
1059                        return;
1060                    }
1061                };
1062                // At most one event per ingested item forwards the item itself;
1063                // moving it out of the slot avoids a clone per streamed delta.
1064                let mut item_slot = Some(item);
1065                while let Some(event) = events.pop_front() {
1066                    match event {
1067                        StreamedTurnEvent::EmitIngested => {
1068                            if self.observes_text_delta
1069                                && let Some(StreamedAssistantContent::Text(text)) =
1070                                    item_slot.as_ref()
1071                                && let Some(reason) = observe_flow(
1072                                    runner
1073                                        .hooks
1074                                        .on_event(hook_ctx, StepEvent::TextDelta {
1075                                            delta: &text.text,
1076                                            aggregated: assembler.aggregated_text(),
1077                                        })
1078                                        .await,
1079                                )
1080                            {
1081                                yield Err(StreamingError::Prompt(Box::new(
1082                                    run.cancel_error(reason),
1083                                )));
1084                                return;
1085                            }
1086                            if let Some(item) = item_slot.take() {
1087                                yield Ok(MultiTurnStreamItem::stream_item(item));
1088                            }
1089                        }
1090                        StreamedTurnEvent::EmitToolCallDelta {
1091                            id,
1092                            internal_call_id,
1093                            content,
1094                        } => {
1095                            if self.observes_tool_call_delta {
1096                                let (delta_name, delta_text) = match &content {
1097                                    ToolCallDeltaContent::Name(name) => (Some(name.as_str()), ""),
1098                                    ToolCallDeltaContent::Delta(delta) => (None, delta.as_str()),
1099                                };
1100                                if let Some(reason) = observe_flow(
1101                                    runner
1102                                        .hooks
1103                                        .on_event(hook_ctx, StepEvent::ToolCallDelta {
1104                                            tool_call_id: &id,
1105                                            internal_call_id: &internal_call_id,
1106                                            tool_name: delta_name,
1107                                            delta: delta_text,
1108                                        })
1109                                        .await,
1110                                ) {
1111                                    yield Err(StreamingError::Prompt(Box::new(
1112                                        run.cancel_error(reason),
1113                                    )));
1114                                    return;
1115                                }
1116                            }
1117
1118                            yield Ok(MultiTurnStreamItem::StreamAssistantItem(
1119                                StreamedAssistantContent::ToolCallDelta {
1120                                    id,
1121                                    internal_call_id,
1122                                    content,
1123                                },
1124                            ));
1125                        }
1126                        StreamedTurnEvent::Completed { usage, emit_final } => {
1127                            match emit_completion_call!(usage) {
1128                                Ok(Some(item)) => yield Ok(item),
1129                                Ok(None) => {}
1130                                Err(err) => {
1131                                    yield Err(err);
1132                                    return;
1133                                }
1134                            }
1135
1136                            if emit_final
1137                                && let Some(StreamedAssistantContent::Final(final_resp)) =
1138                                    item_slot.as_ref()
1139                            {
1140                                if !turn_recovered
1141                                    && let Some(reason) = observe_flow(
1142                                        runner
1143                                            .hooks
1144                                            .on_event(hook_ctx, StepEvent::StreamResponseFinish {
1145                                                prompt: &current_prompt,
1146                                                response: final_resp,
1147                                            })
1148                                            .await,
1149                                    )
1150                                {
1151                                    yield Err(StreamingError::Prompt(Box::new(
1152                                        run.cancel_error(reason),
1153                                    )));
1154                                    return;
1155                                }
1156                                if let Some(item) = item_slot.take() {
1157                                    yield Ok(MultiTurnStreamItem::stream_item(item));
1158                                }
1159                            }
1160                        }
1161                        StreamedTurnEvent::InvalidToolCall(invalid) => {
1162                            let partial = assembler.partial_turn(stream.message_id.clone());
1163                            // Gated on `has_hooks`: building the diagnostic context
1164                            // clones the chat history, so an empty stack skips it and
1165                            // fails fast — identical to the blocking path.
1166                            let action = if self.has_hooks {
1167                                let context =
1168                                    run.streamed_invalid_tool_call_context(&partial, &invalid);
1169                                match flow_into_invalid(
1170                                    runner
1171                                        .hooks
1172                                        .on_event(hook_ctx, StepEvent::InvalidToolCall(&context))
1173                                        .await,
1174                                ) {
1175                                    InvalidDecision::Action(action) => action,
1176                                    InvalidDecision::Terminate(reason) => {
1177                                        yield Err(StreamingError::Prompt(Box::new(
1178                                            run.cancel_error(reason),
1179                                        )));
1180                                        return;
1181                                    }
1182                                }
1183                            } else {
1184                                InvalidToolCallHookAction::fail()
1185                            };
1186
1187                            let resolution =
1188                                match run.resolve_streamed_invalid_tool_call(&partial, &invalid, action) {
1189                                    Ok(resolution) => resolution,
1190                                    Err(err) => {
1191                                        yield Err(Box::new(err).into());
1192                                        return;
1193                                    }
1194                                };
1195
1196                            match resolution {
1197                                StreamedResolution::Repaired { .. } => {
1198                                    // Replayed deltas flow through the same event
1199                                    // handling above; the turn is now recovered, so
1200                                    // its response-finish hook is suppressed.
1201                                    turn_recovered = true;
1202                                    events.extend(assembler.resolve_pending_invalid(&resolution));
1203                                }
1204                                StreamedResolution::TurnAbandoned {
1205                                    ref skipped_tool_result,
1206                                } => {
1207                                    let skipped_tool_result = skipped_tool_result.clone();
1208                                    assembler.resolve_pending_invalid(&resolution);
1209
1210                                    if let Some(err) = assembler.pending_delta_error() {
1211                                        yield Err(err.into());
1212                                        return;
1213                                    }
1214                                    let drained_usage = match drain_stream_usage(&mut stream).await {
1215                                        Ok(usage) => usage,
1216                                        Err(err) => {
1217                                            yield Err(err);
1218                                            return;
1219                                        }
1220                                    };
1221                                    match emit_completion_call!(drained_usage) {
1222                                        Ok(Some(item)) => yield Ok(item),
1223                                        Ok(None) => {}
1224                                        Err(err) => {
1225                                            yield Err(err);
1226                                            return;
1227                                        }
1228                                    }
1229                                    if let Some(tool_result) = skipped_tool_result {
1230                                        yield Ok(MultiTurnStreamItem::StreamUserItem(
1231                                            StreamedUserContent::ToolResult {
1232                                                tool_result,
1233                                                internal_call_id: invalid.internal_call_id.clone(),
1234                                            },
1235                                        ));
1236                                    }
1237                                    turn_abandoned = true;
1238                                    break 'turn;
1239                                }
1240                            }
1241                        }
1242                    }
1243                }
1244            }
1245
1246            if turn_abandoned {
1247                return;
1248            }
1249
1250            if let Some(err) = assembler.pending_delta_error() {
1251                yield Err(err.into());
1252                return;
1253            }
1254
1255            // Final fallback: no usage was ever learned, so there is nothing to
1256            // record onto the span and this is the last read of the flag — kept
1257            // inline (not `emit_completion_call!`) so it doesn't emit a dead
1258            // `completion_call_emitted = true` write.
1259            if !completion_call_emitted {
1260                match run.record_streamed_completion_call(crate::completion::Usage::new()) {
1261                    Ok(call) => yield Ok(MultiTurnStreamItem::CompletionCall(call)),
1262                    Err(err) => {
1263                        yield Err(Box::new(err).into());
1264                        return;
1265                    }
1266                }
1267            }
1268
1269            let final_turn_content = stream.choice.clone();
1270            // Only record onto the agent span when we own it — never pollute a
1271            // caller-supplied span (parity with the blocking source).
1272            if self.created_agent_span {
1273                agent_span.record(
1274                    "gen_ai.completion",
1275                    assistant_text_from_choice(&final_turn_content),
1276                );
1277            }
1278
1279            self.last_message_id = stream.message_id.clone();
1280            let streamed_turn = assembler.finish(stream.message_id.clone(), &final_turn_content);
1281            // The canonical (committed) assistant content: `finish` normalizes
1282            // reasoning/text/tool ordering, so this can differ from the raw
1283            // `stream.choice` aggregate. `ModelTurnFinished` — the normalized
1284            // per-turn event — carries this, matching what is recorded into run
1285            // history; the raw `stream.choice` is kept in `last_final_choice` for
1286            // the raw/final streaming behavior.
1287            let canonical_choice = streamed_turn.choice.clone();
1288            if let Err(err) = run.streamed_turn(streamed_turn) {
1289                yield Err(Box::new(err).into());
1290                return;
1291            }
1292
1293            // Normalized per-turn event, fired once the turn is committed on the
1294            // streaming surface — including tool-only / reasoning-only turns that
1295            // fire no `StreamResponseFinish`. Suppressed for recovered turns,
1296            // mirroring the blocking surface's `Continue` arm.
1297            if !turn_recovered
1298                && let Some(reason) = observe_flow(
1299                    runner
1300                        .hooks
1301                        .on_event(hook_ctx, StepEvent::ModelTurnFinished {
1302                            turn: hook_ctx.turn(),
1303                            content: &canonical_choice,
1304                            usage: last_usage,
1305                        })
1306                        .await,
1307                )
1308            {
1309                yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
1310                return;
1311            }
1312
1313            self.last_final_choice = final_turn_content;
1314        })
1315    }
1316
1317    fn run_tool_calls<'a>(
1318        &'a self,
1319        runner: &'a AgentRunner<M>,
1320        hook_ctx: &'a HookContext,
1321        run: &'a mut AgentRun,
1322        calls: Vec<PendingToolCall>,
1323    ) -> DriveStream<'a, M::StreamingResponse> {
1324        // The streaming surface chains nothing onto its tool spans, and forwards
1325        // the ToolCall/ToolResult items to the consumer.
1326        drive_tool_calls(runner, hook_ctx, run, calls, |span| span, true)
1327    }
1328
1329    fn record_run_level_telemetry(
1330        &self,
1331        agent_span: &tracing::Span,
1332        response: &PromptResponse,
1333        created_agent_span: bool,
1334    ) {
1335        if created_agent_span {
1336            record_usage_on_span(agent_span, response.usage);
1337        }
1338    }
1339
1340    fn final_item(
1341        &self,
1342        response: &PromptResponse,
1343    ) -> Option<MultiTurnStreamItem<M::StreamingResponse>> {
1344        // Tool output mode (#1928): when the finishing turn made the output-tool
1345        // call, surface the run's structured output as the final content.
1346        let final_choice = finalize_streamed_choice(&self.last_final_choice, &response.output)
1347            .unwrap_or_else(|| {
1348                if is_empty_assistant_turn(&self.last_final_choice) {
1349                    tracing::warn!(
1350                        agent_name = self.agent_name.as_str(),
1351                        message_id = ?self.last_message_id,
1352                        "Streaming turn completed without assistant text; final response will be empty"
1353                    );
1354                }
1355                self.last_final_choice.clone()
1356            });
1357        // Always surface the accumulated messages (parity with the blocking
1358        // `run()`), regardless of whether the caller supplied input history.
1359        let final_messages: Option<Vec<Message>> =
1360            Some(response.messages.clone().unwrap_or_default());
1361        Some(MultiTurnStreamItem::final_response_with_completion_calls(
1362            final_choice,
1363            response.usage,
1364            response.completion_calls.clone(),
1365            final_messages,
1366        ))
1367    }
1368}
1369
1370impl<M> AgentRunner<M>
1371where
1372    M: CompletionModel + 'static,
1373    <M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
1374{
1375    /// Drive the agent loop, streaming assistant content, tool activity, and a
1376    /// final response. Hooks fire at every observable point, including streamed
1377    /// text and tool-call deltas. Returns the stream after loading any
1378    /// configured conversation memory.
1379    ///
1380    /// Shares the drive loop, run construction, tool execution and fail-closed
1381    /// hook handling with the blocking [`run`](AgentRunner::run) via
1382    /// `drive_agent`, so the two behave identically apart from the streamed
1383    /// delta events.
1384    pub async fn stream(self) -> StreamingResult<M::StreamingResponse> {
1385        let (agent_span, created_agent_span) =
1386            acquire_agent_span(self.agent_name_or_default(), self.preamble.as_deref());
1387
1388        if let Some(text) = self.prompt.rag_text() {
1389            agent_span.record("gen_ai.prompt", text);
1390        }
1391
1392        // When the caller passes explicit history, memory is fully bypassed for
1393        // this request (no load AND no save). Otherwise, if a memory backend and
1394        // conversation id are both configured, load prior history.
1395        let (history_override, memory_handle) = match &self.chat_history {
1396            Some(_) => (None, None),
1397            None => match (&self.memory, &self.conversation_id) {
1398                (Some(memory), Some(id)) => match memory.load(id).await {
1399                    Ok(loaded) => (Some(loaded), Some((memory.clone(), id.clone()))),
1400                    Err(err) => {
1401                        let stream = async_stream::stream! {
1402                            yield Err(StreamingError::from(err));
1403                        };
1404                        // Instrument under the agent span like the success path so
1405                        // a load failure stays tied to invoke_agent.
1406                        return Box::pin(stream.instrument(agent_span));
1407                    }
1408                },
1409                _ => (None, None),
1410            },
1411        };
1412
1413        let run = self.build_run(history_override);
1414        let source = StreamingTurnSource::new(
1415            &self.hooks,
1416            self.agent_name_or_default().to_string(),
1417            created_agent_span,
1418        );
1419
1420        // The blocking surface folds this same engine; the streaming surface
1421        // forwards intermediate items (the final response item is the last one)
1422        // and ends on `Done`.
1423        let driver = drive_agent(
1424            self,
1425            source,
1426            run,
1427            agent_span.clone(),
1428            created_agent_span,
1429            memory_handle,
1430            true,
1431        )
1432        .filter_map(|item| {
1433            std::future::ready(match item {
1434                Ok(DriveItem::Item(item)) => Some(Ok(item)),
1435                Ok(DriveItem::Done(_)) => None,
1436                Err(err) => Some(Err(err)),
1437            })
1438        });
1439
1440        Box::pin(driver.instrument(agent_span))
1441    }
1442}
1443
1444impl<M> IntoFuture for StreamingPromptRequest<M>
1445where
1446    M: CompletionModel + 'static,
1447    <M as CompletionModel>::StreamingResponse: WasmCompatSend,
1448{
1449    type Output = StreamingResult<M::StreamingResponse>; // what `.await` returns
1450    type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
1451
1452    fn into_future(self) -> Self::IntoFuture {
1453        // Wrap send() in a future, because send() returns a stream immediately
1454        Box::pin(async move { self.send().await })
1455    }
1456}
1457
1458/// Helper function to stream assistant-visible completion output to stdout.
1459///
1460/// This helper prints streamed assistant text and reasoning only. Streaming
1461/// metadata events, such as `MultiTurnStreamItem::CompletionCall`, are not
1462/// printed; metadata is returned on the [`PromptResponse`] via accessors such as
1463/// [`PromptResponse::completion_calls`].
1464pub async fn stream_to_stdout<R>(
1465    stream: &mut StreamingResult<R>,
1466) -> Result<PromptResponse, std::io::Error> {
1467    let mut final_res = PromptResponse::empty();
1468    print!("Response: ");
1469    while let Some(content) = stream.next().await {
1470        match content {
1471            Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
1472                Text { text, .. },
1473            ))) => {
1474                print!("{text}");
1475                std::io::Write::flush(&mut std::io::stdout())?;
1476            }
1477            Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning(
1478                reasoning,
1479            ))) => {
1480                let reasoning = reasoning.display_text();
1481                print!("{reasoning}");
1482                std::io::Write::flush(&mut std::io::stdout())?;
1483            }
1484            Ok(MultiTurnStreamItem::FinalResponse(res)) => {
1485                final_res = res;
1486            }
1487            Err(err) => {
1488                eprintln!("Error: {err}");
1489            }
1490            _ => {}
1491        }
1492    }
1493
1494    Ok(final_res)
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499    use super::*;
1500    use crate::agent::AgentBuilder;
1501    use crate::agent::hook::{AgentHook, Flow, HookContext, InvalidToolCallContext, StepEvent};
1502    use crate::agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_output};
1503    use crate::agent::run::streamed::merge_reasoning_blocks;
1504    use crate::client::ProviderClient;
1505    use crate::client::completion::CompletionClient;
1506    use crate::completion::{CompletionRequest, PromptError, ToolDefinition, Usage};
1507    use crate::message::{
1508        AssistantContent, DocumentSourceKind, ImageMediaType, Message, ReasoningContent,
1509        ToolChoice, ToolResultContent, UserContent,
1510    };
1511    use crate::providers::anthropic;
1512    use crate::streaming::{StreamingPrompt, ToolCallDeltaContent};
1513    use crate::test_utils::{
1514        AppendFailingMemory, FailingMemory, MockAddTool, MockBarrierTool, MockCompletionModel,
1515        MockExtensionsProbeTool, MockResponse, MockStreamEvent, MockSubtractTool, MockToolError,
1516        SessionId,
1517    };
1518    use crate::tool::{Tool, ToolCallExtensions};
1519    use futures::{StreamExt, TryStreamExt};
1520    use serde::Deserialize;
1521    use std::collections::HashMap;
1522    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1523    use std::sync::{Arc, Mutex};
1524    use std::time::Duration;
1525    use tracing::field::{Field, Visit};
1526    use tracing::{Id, Subscriber};
1527    use tracing_subscriber::layer::{Context, SubscriberExt};
1528    use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
1529
1530    #[test]
1531    fn finalize_streamed_choice_surfaces_output_over_tool_call_and_prose() {
1532        use crate::message::{ToolCall, ToolFunction};
1533
1534        let output_call = AssistantContent::ToolCall(ToolCall::new(
1535            "c1".to_string(),
1536            ToolFunction::new(
1537                "final_result".to_string(),
1538                serde_json::json!({"city": "Tokyo"}),
1539            ),
1540        ));
1541
1542        // Prose + output-tool call (#1928): the streamed response text must be
1543        // the structured output, not the prose, with no orphan tool_use.
1544        let with_prose = OneOrMany::many(vec![
1545            AssistantContent::text("Sure, here is the weather:"),
1546            output_call.clone(),
1547        ])
1548        .expect("two items");
1549        let final_choice = finalize_streamed_choice(&with_prose, r#"{"city":"Tokyo"}"#)
1550            .expect("a turn with the output-tool call is finalized via it");
1551        assert_eq!(
1552            assistant_text_from_choice(&final_choice),
1553            r#"{"city":"Tokyo"}"#
1554        );
1555        assert!(
1556            !final_choice
1557                .iter()
1558                .any(|item| matches!(item, AssistantContent::ToolCall(_))),
1559            "no unanswered tool_use should remain in the final content"
1560        );
1561
1562        // Output-tool call only.
1563        let only_call = OneOrMany::one(output_call);
1564        let final_choice = finalize_streamed_choice(&only_call, r#"{"city":"Tokyo"}"#)
1565            .expect("finalized via output tool");
1566        assert_eq!(
1567            assistant_text_from_choice(&final_choice),
1568            r#"{"city":"Tokyo"}"#
1569        );
1570
1571        // A plain-text finalize (no tool call) is left to the caller.
1572        let text_only = OneOrMany::one(AssistantContent::text(r#"{"city":"Tokyo"}"#));
1573        assert!(finalize_streamed_choice(&text_only, r#"{"city":"Tokyo"}"#).is_none());
1574    }
1575
1576    #[test]
1577    fn merge_reasoning_blocks_preserves_order_and_signatures() {
1578        let mut accumulated = Vec::new();
1579        let first = crate::message::Reasoning {
1580            id: Some("rs_1".to_string()),
1581            content: vec![ReasoningContent::Text {
1582                text: "step-1".to_string(),
1583                signature: Some("sig-1".to_string()),
1584            }],
1585        };
1586        let second = crate::message::Reasoning {
1587            id: Some("rs_1".to_string()),
1588            content: vec![
1589                ReasoningContent::Text {
1590                    text: "step-2".to_string(),
1591                    signature: Some("sig-2".to_string()),
1592                },
1593                ReasoningContent::Summary("summary".to_string()),
1594            ],
1595        };
1596
1597        merge_reasoning_blocks(&mut accumulated, &first);
1598        merge_reasoning_blocks(&mut accumulated, &second);
1599
1600        assert_eq!(accumulated.len(), 1);
1601        let merged = accumulated.first().expect("expected accumulated reasoning");
1602        assert_eq!(merged.id.as_deref(), Some("rs_1"));
1603        assert_eq!(merged.content.len(), 3);
1604        assert!(matches!(
1605            merged.content.first(),
1606            Some(ReasoningContent::Text { text, signature: Some(sig) })
1607                if text == "step-1" && sig == "sig-1"
1608        ));
1609        assert!(matches!(
1610            merged.content.get(1),
1611            Some(ReasoningContent::Text { text, signature: Some(sig) })
1612                if text == "step-2" && sig == "sig-2"
1613        ));
1614    }
1615
1616    #[test]
1617    fn merge_reasoning_blocks_keeps_distinct_ids_as_separate_items() {
1618        let mut accumulated = vec![crate::message::Reasoning {
1619            id: Some("rs_a".to_string()),
1620            content: vec![ReasoningContent::Text {
1621                text: "step-1".to_string(),
1622                signature: None,
1623            }],
1624        }];
1625        let incoming = crate::message::Reasoning {
1626            id: Some("rs_b".to_string()),
1627            content: vec![ReasoningContent::Text {
1628                text: "step-2".to_string(),
1629                signature: None,
1630            }],
1631        };
1632
1633        merge_reasoning_blocks(&mut accumulated, &incoming);
1634        assert_eq!(accumulated.len(), 2);
1635        assert_eq!(
1636            accumulated.first().and_then(|r| r.id.as_deref()),
1637            Some("rs_a")
1638        );
1639        assert_eq!(
1640            accumulated.get(1).and_then(|r| r.id.as_deref()),
1641            Some("rs_b")
1642        );
1643    }
1644
1645    #[test]
1646    fn merge_reasoning_blocks_keeps_none_ids_separate_items() {
1647        let mut accumulated = vec![crate::message::Reasoning {
1648            id: None,
1649            content: vec![ReasoningContent::Text {
1650                text: "first".to_string(),
1651                signature: None,
1652            }],
1653        }];
1654        let incoming = crate::message::Reasoning {
1655            id: None,
1656            content: vec![ReasoningContent::Text {
1657                text: "second".to_string(),
1658                signature: None,
1659            }],
1660        };
1661
1662        merge_reasoning_blocks(&mut accumulated, &incoming);
1663        assert_eq!(accumulated.len(), 2);
1664        assert!(matches!(
1665            accumulated.first(),
1666            Some(crate::message::Reasoning {
1667                id: None,
1668                content
1669            }) if matches!(
1670                content.first(),
1671                Some(ReasoningContent::Text { text, .. }) if text == "first"
1672            )
1673        ));
1674        assert!(matches!(
1675            accumulated.get(1),
1676            Some(crate::message::Reasoning {
1677                id: None,
1678                content
1679            }) if matches!(
1680                content.first(),
1681                Some(ReasoningContent::Text { text, .. }) if text == "second"
1682            )
1683        ));
1684    }
1685
1686    #[test]
1687    fn tool_result_output_preserves_multimodal_tool_output() {
1688        let user_content = tool_result_output(
1689            "tool_call_1".to_string(),
1690            Some("call_1".to_string()),
1691            serde_json::json!({
1692                "response": {
1693                    "instruction": "Use the image part to answer."
1694                },
1695                "parts": [
1696                    {
1697                        "type": "image",
1698                        "data": "base64data==",
1699                        "mimeType": "image/png"
1700                    }
1701                ]
1702            })
1703            .to_string(),
1704        );
1705
1706        let tool_result = match user_content {
1707            UserContent::ToolResult(tool_result) => tool_result,
1708            other => panic!("expected tool result content, got {other:?}"),
1709        };
1710
1711        assert_eq!(tool_result.id, "tool_call_1");
1712        assert_eq!(tool_result.call_id.as_deref(), Some("call_1"));
1713        assert_eq!(tool_result.content.len(), 2);
1714
1715        let mut items = tool_result.content.iter();
1716        match items.next() {
1717            Some(ToolResultContent::Text(text)) => {
1718                assert!(text.text.contains("Use the image part to answer."));
1719            }
1720            other => panic!("expected structured text payload first, got {other:?}"),
1721        }
1722
1723        match items.next() {
1724            Some(ToolResultContent::Image(image)) => {
1725                assert_eq!(image.media_type, Some(ImageMediaType::PNG));
1726                assert!(matches!(
1727                    image.data,
1728                    DocumentSourceKind::Base64(ref data) if data == "base64data=="
1729                ));
1730            }
1731            other => panic!("expected image payload second, got {other:?}"),
1732        }
1733    }
1734
1735    fn validate_follow_up_tool_history(request: &CompletionRequest) -> Result<(), String> {
1736        let history = request.chat_history.iter().cloned().collect::<Vec<_>>();
1737        if history.len() != 3 {
1738            return Err(format!(
1739                "follow-up request should contain [original user prompt, assistant tool call, user tool result]: {history:?}"
1740            ));
1741        }
1742
1743        if !matches!(
1744            history.first(),
1745            Some(Message::User { content })
1746                if matches!(
1747                    content.first(),
1748                    UserContent::Text(text) if text.text == "do tool work"
1749                )
1750        ) {
1751            return Err(format!(
1752                "follow-up request should begin with the original user prompt: {history:?}"
1753            ));
1754        }
1755
1756        if !matches!(
1757            history.get(1),
1758            Some(Message::Assistant { content, .. })
1759                if matches!(
1760                    content.first(),
1761                    AssistantContent::ToolCall(tool_call)
1762                        if tool_call.id == "tool_call_1"
1763                            && tool_call.call_id.as_deref() == Some("call_1")
1764                )
1765        ) {
1766            return Err(format!(
1767                "follow-up request is missing the assistant tool call in position 2: {history:?}"
1768            ));
1769        }
1770
1771        if !matches!(
1772            history.get(2),
1773            Some(Message::User { content })
1774                if matches!(
1775                    content.first(),
1776                    UserContent::ToolResult(tool_result)
1777                        if tool_result.id == "tool_call_1"
1778                            && tool_result.call_id.as_deref() == Some("call_1")
1779                )
1780        ) {
1781            return Err(format!(
1782                "follow-up request should end with the user tool result: {history:?}"
1783            ));
1784        }
1785
1786        Ok(())
1787    }
1788
1789    fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1790        history.iter().any(|message| {
1791            matches!(
1792                message,
1793                Message::Assistant { content, .. }
1794                    if content.iter().any(|item| matches!(
1795                        item,
1796                        AssistantContent::ToolCall(tool_call)
1797                            if tool_call.function.name == tool_name
1798                    ))
1799            )
1800        })
1801    }
1802
1803    fn history_contains_text(history: &[Message], expected: &str) -> bool {
1804        history.iter().any(|message| {
1805            matches!(
1806                message,
1807                Message::Assistant { content, .. }
1808                    if content.iter().any(|item| matches!(
1809                        item,
1810                        AssistantContent::Text(text) if text.text == expected
1811                    ))
1812            )
1813        })
1814    }
1815
1816    fn assistant_reasoning_precedes_tool_call(
1817        history: &[Message],
1818        expected_reasoning: &str,
1819        tool_name: &str,
1820    ) -> bool {
1821        history.iter().any(|message| {
1822            let Message::Assistant { content, .. } = message else {
1823                return false;
1824            };
1825
1826            let reasoning_index = content.iter().position(|item| {
1827                matches!(
1828                    item,
1829                    AssistantContent::Reasoning(reasoning)
1830                        if reasoning.content.iter().any(|content| matches!(
1831                            content,
1832                            ReasoningContent::Text { text, .. }
1833                                if text == expected_reasoning
1834                        ))
1835                )
1836            });
1837            let tool_index = content.iter().position(|item| {
1838                matches!(
1839                    item,
1840                    AssistantContent::ToolCall(tool_call)
1841                        if tool_call.function.name == tool_name
1842                )
1843            });
1844
1845            matches!((reasoning_index, tool_index), (Some(reasoning), Some(tool)) if reasoning < tool)
1846        })
1847    }
1848
1849    fn assistant_reasoning_precedes_text_and_tool_call(
1850        history: &[Message],
1851        expected_reasoning: &str,
1852        expected_text: &str,
1853        tool_name: &str,
1854    ) -> bool {
1855        history.iter().any(|message| {
1856            let Message::Assistant { content, .. } = message else {
1857                return false;
1858            };
1859
1860            let reasoning_index = content.iter().position(|item| {
1861                matches!(
1862                    item,
1863                    AssistantContent::Reasoning(reasoning)
1864                        if reasoning.content.iter().any(|content| matches!(
1865                            content,
1866                            ReasoningContent::Text { text, .. }
1867                                if text == expected_reasoning
1868                        ))
1869                )
1870            });
1871            let text_index = content.iter().position(|item| {
1872                matches!(
1873                    item,
1874                    AssistantContent::Text(text) if text.text == expected_text
1875                )
1876            });
1877            let tool_index = content.iter().position(|item| {
1878                matches!(
1879                    item,
1880                    AssistantContent::ToolCall(tool_call)
1881                        if tool_call.function.name == tool_name
1882                )
1883            });
1884
1885            matches!(
1886                (reasoning_index, text_index, tool_index),
1887                (Some(reasoning), Some(text), Some(tool))
1888                    if reasoning < text && text < tool
1889            )
1890        })
1891    }
1892
1893    #[derive(Clone)]
1894    struct PanicOnUnknownToolHook;
1895
1896    impl AgentHook<MockCompletionModel> for PanicOnUnknownToolHook {
1897        async fn on_event(
1898            &self,
1899            _ctx: &HookContext,
1900            event: StepEvent<'_, MockCompletionModel>,
1901        ) -> Flow {
1902            match event {
1903                StepEvent::ToolCallDelta { .. } => {
1904                    panic!("unknown tool call delta should fail before delta hooks run")
1905                }
1906                StepEvent::ToolCall { .. } => {
1907                    panic!("unknown tool call should fail before tool hooks run")
1908                }
1909                StepEvent::StreamResponseFinish { .. } => {
1910                    panic!("unknown tool call should fail before stream finish hooks run")
1911                }
1912                _ => Flow::cont(),
1913            }
1914        }
1915    }
1916
1917    #[derive(Clone)]
1918    struct CountingAddTool {
1919        calls: Arc<AtomicU32>,
1920    }
1921
1922    #[derive(Clone)]
1923    struct CountingSubtractTool {
1924        calls: Arc<AtomicU32>,
1925    }
1926
1927    #[derive(Deserialize)]
1928    struct CountingOperationArgs {
1929        x: i32,
1930        y: i32,
1931    }
1932
1933    fn arithmetic_tool_definition(name: &str, description: &str) -> ToolDefinition {
1934        ToolDefinition {
1935            name: name.to_string(),
1936            description: description.to_string(),
1937            parameters: serde_json::json!({
1938                "type": "object",
1939                "properties": {
1940                    "x": {
1941                        "type": "number",
1942                        "description": "The first operand"
1943                    },
1944                    "y": {
1945                        "type": "number",
1946                        "description": "The second operand"
1947                    }
1948                },
1949                "required": ["x", "y"],
1950            }),
1951        }
1952    }
1953
1954    impl Tool for CountingAddTool {
1955        const NAME: &'static str = "add";
1956        type Error = MockToolError;
1957        type Args = CountingOperationArgs;
1958        type Output = i32;
1959
1960        fn description(&self) -> String {
1961            "Add x and y together".to_string()
1962        }
1963
1964        fn parameters(&self) -> serde_json::Value {
1965            arithmetic_tool_definition(Self::NAME, "Add x and y together").parameters
1966        }
1967
1968        async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
1969            self.calls.fetch_add(1, Ordering::SeqCst);
1970            Ok(args.x + args.y)
1971        }
1972    }
1973
1974    impl Tool for CountingSubtractTool {
1975        const NAME: &'static str = "subtract";
1976        type Error = MockToolError;
1977        type Args = CountingOperationArgs;
1978        type Output = i32;
1979
1980        fn description(&self) -> String {
1981            "Subtract y from x".to_string()
1982        }
1983
1984        fn parameters(&self) -> serde_json::Value {
1985            arithmetic_tool_definition(Self::NAME, "Subtract y from x").parameters
1986        }
1987
1988        async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
1989            self.calls.fetch_add(1, Ordering::SeqCst);
1990            Ok(args.x - args.y)
1991        }
1992    }
1993
1994    fn streaming_tool_then_text_model() -> MockCompletionModel {
1995        MockCompletionModel::from_stream_turns([
1996            vec![
1997                MockStreamEvent::tool_call(
1998                    "tool_call_1",
1999                    "add",
2000                    serde_json::json!({"x": 1, "y": 2}),
2001                )
2002                .with_call_id("call_1"),
2003                MockStreamEvent::final_response_with_total_tokens(4),
2004            ],
2005            vec![
2006                MockStreamEvent::text("done"),
2007                MockStreamEvent::final_response_with_total_tokens(6),
2008            ],
2009        ])
2010    }
2011
2012    fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
2013        Usage {
2014            input_tokens,
2015            output_tokens,
2016            total_tokens: input_tokens + output_tokens,
2017            cached_input_tokens: 0,
2018            cache_creation_input_tokens: 0,
2019            tool_use_prompt_tokens: 0,
2020            reasoning_tokens: 0,
2021        }
2022    }
2023
2024    #[derive(Clone, Debug, Default)]
2025    struct CapturedSpan {
2026        id: u64,
2027        name: String,
2028        parent_id: Option<u64>,
2029        fields: HashMap<String, u64>,
2030    }
2031
2032    #[derive(Clone, Default)]
2033    struct CapturedSpans(Arc<Mutex<Vec<CapturedSpan>>>);
2034
2035    impl CapturedSpans {
2036        fn clear(&self) {
2037            if let Ok(mut spans) = self.0.lock() {
2038                spans.clear();
2039            }
2040        }
2041
2042        fn insert(&self, id: &Id, name: &str, parent_id: Option<u64>) {
2043            let id = id.into_u64();
2044            if let Ok(mut spans) = self.0.lock() {
2045                spans.push(CapturedSpan {
2046                    id,
2047                    name: name.to_string(),
2048                    parent_id,
2049                    fields: HashMap::new(),
2050                });
2051            }
2052        }
2053
2054        fn record(&self, id: &Id, fields: Vec<(String, u64)>) {
2055            if let Ok(mut spans) = self.0.lock()
2056                && let Some(span) = spans.iter_mut().rev().find(|span| span.id == id.into_u64())
2057            {
2058                span.fields.extend(fields);
2059            }
2060        }
2061
2062        fn snapshot(&self) -> Vec<CapturedSpan> {
2063            self.0.lock().map(|spans| spans.clone()).unwrap_or_default()
2064        }
2065    }
2066
2067    struct SpanCaptureLayer {
2068        spans: CapturedSpans,
2069    }
2070
2071    impl<S> Layer<S> for SpanCaptureLayer
2072    where
2073        S: Subscriber,
2074        S: for<'lookup> LookupSpan<'lookup>,
2075    {
2076        fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
2077            let parent_id = attrs
2078                .parent()
2079                .map(Id::into_u64)
2080                .or_else(|| ctx.current_span().id().map(Id::into_u64));
2081            self.spans.insert(id, attrs.metadata().name(), parent_id);
2082        }
2083
2084        fn on_record(&self, span: &Id, values: &tracing::span::Record<'_>, _ctx: Context<'_, S>) {
2085            let mut fields = Vec::new();
2086            values.record(&mut SpanFieldCaptureVisitor {
2087                fields: &mut fields,
2088            });
2089            self.spans.record(span, fields);
2090        }
2091    }
2092
2093    struct SpanFieldCaptureVisitor<'a> {
2094        fields: &'a mut Vec<(String, u64)>,
2095    }
2096
2097    impl Visit for SpanFieldCaptureVisitor<'_> {
2098        fn record_u64(&mut self, field: &Field, value: u64) {
2099            self.fields.push((field.name().to_string(), value));
2100        }
2101
2102        // Capture the *presence* of non-numeric fields (e.g. `gen_ai.completion`)
2103        // with a placeholder value so tests can assert whether they were recorded.
2104        fn record_str(&mut self, field: &Field, _value: &str) {
2105            self.fields.push((field.name().to_string(), 0));
2106        }
2107
2108        fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
2109            self.fields.push((field.name().to_string(), 0));
2110        }
2111    }
2112
2113    async fn assert_stream_usage_recorded_on_chat_spans(
2114        agent: crate::agent::Agent<MockCompletionModel>,
2115        prompt: &str,
2116        max_turns: usize,
2117        expected_usages: &[Usage],
2118    ) {
2119        // Scoped-subscriber tests must not run concurrently; the warm-up
2120        // below explains the callsite-interest hazard this guards against.
2121        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2122        let spans = CapturedSpans::default();
2123        let subscriber = Registry::default().with(SpanCaptureLayer {
2124            spans: spans.clone(),
2125        });
2126        let _default = tracing::subscriber::set_default(subscriber);
2127
2128        // Span callsites in the driver are shared with every other test in
2129        // this binary. The FIRST thread to hit a callsite caches its interest
2130        // from that thread's dispatcher (`Dispatchers::Rebuilder::JustOne`
2131        // consults `dispatcher::get_default`), so a parallel test without a
2132        // subscriber can permanently cache `Interest::never` for the very
2133        // spans this harness asserts on. Defend in two steps, both under the
2134        // isolation guard: (1) warm the whole driver path from THIS thread so
2135        // unregistered callsites first-register against this subscriber, then
2136        // (2) rebuild the interest cache to heal callsites a foreign thread
2137        // already poisoned.
2138        let warmup_model = MockCompletionModel::from_stream_turns([[
2139            MockStreamEvent::text("warmup"),
2140            MockStreamEvent::final_response(Usage::default()),
2141        ]]);
2142        let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2143        let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2144        while let Some(item) = warmup_stream
2145            .try_next()
2146            .await
2147            .expect("warmup stream should not error")
2148        {
2149            if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2150                break;
2151            }
2152        }
2153        tracing::callsite::rebuild_interest_cache();
2154        spans.clear();
2155
2156        let empty_history: &[Message] = &[];
2157        // Declare the fields the guard protects so a regression (recording onto
2158        // a caller span) is actually observable, not silently a no-op.
2159        let outer_span = tracing::info_span!("outer", gen_ai.completion = tracing::field::Empty);
2160
2161        async {
2162            let mut stream = agent
2163                .stream_prompt(prompt)
2164                .history(empty_history)
2165                .max_turns(max_turns)
2166                .await;
2167
2168            while let Some(item) = stream.try_next().await.expect("stream should not error") {
2169                if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2170                    break;
2171                }
2172            }
2173        }
2174        .instrument(outer_span)
2175        .await;
2176
2177        let span_snapshot = spans.snapshot();
2178        let outer_span_id = span_snapshot
2179            .iter()
2180            .find(|span| span.name == "outer")
2181            .map(|span| span.id)
2182            .expect("outer span should be captured");
2183        let chat_spans = span_snapshot
2184            .iter()
2185            .filter(|span| span.name == "chat_streaming")
2186            .collect::<Vec<_>>();
2187
2188        assert_eq!(chat_spans.len(), expected_usages.len());
2189        assert!(
2190            span_snapshot.iter().all(|span| span.name != "invoke_agent"),
2191            "outer span path should not create invoke_agent"
2192        );
2193
2194        for (chat_span, expected_usage) in chat_spans.into_iter().zip(expected_usages) {
2195            assert_eq!(chat_span.parent_id, Some(outer_span_id));
2196            assert_eq!(
2197                chat_span.fields.get("gen_ai.usage.input_tokens"),
2198                Some(&expected_usage.input_tokens)
2199            );
2200            assert_eq!(
2201                chat_span.fields.get("gen_ai.usage.output_tokens"),
2202                Some(&expected_usage.output_tokens)
2203            );
2204            assert_eq!(
2205                chat_span.fields.get("gen_ai.usage.cache_read.input_tokens"),
2206                Some(&expected_usage.cached_input_tokens)
2207            );
2208            assert_eq!(
2209                chat_span
2210                    .fields
2211                    .get("gen_ai.usage.cache_creation.input_tokens"),
2212                Some(&expected_usage.cache_creation_input_tokens)
2213            );
2214            assert_eq!(
2215                chat_span.fields.get("gen_ai.usage.tool_use_prompt_tokens"),
2216                Some(&expected_usage.tool_use_prompt_tokens)
2217            );
2218            assert_eq!(
2219                chat_span.fields.get("gen_ai.usage.reasoning_tokens"),
2220                Some(&expected_usage.reasoning_tokens)
2221            );
2222        }
2223
2224        let outer_span = span_snapshot
2225            .iter()
2226            .find(|span| span.id == outer_span_id)
2227            .expect("outer span should be present");
2228        assert!(
2229            outer_span
2230                .fields
2231                .keys()
2232                .all(|field| !field.starts_with("gen_ai.usage.")),
2233            "usage should not be recorded onto the caller's outer span"
2234        );
2235        assert!(
2236            !outer_span.fields.contains_key("gen_ai.completion"),
2237            "gen_ai.completion should not be recorded onto the caller's outer span \
2238             (parity with the blocking driver)"
2239        );
2240    }
2241
2242    #[test]
2243    fn completion_calls_stream_item_serializes_and_deserializes_expected_shape() {
2244        let item: MultiTurnStreamItem<MockResponse> =
2245            MultiTurnStreamItem::CompletionCall(CompletionCall::new(2, usage(3, 4)));
2246
2247        let value = serde_json::to_value(&item).expect("serialize completion call event");
2248
2249        assert_eq!(
2250            value,
2251            serde_json::json!({
2252                "type": "completionCall",
2253                "call_index": 2,
2254                "usage": {
2255                    "input_tokens": 3,
2256                    "output_tokens": 4,
2257                    "total_tokens": 7,
2258                    "cached_input_tokens": 0,
2259                    "cache_creation_input_tokens": 0,
2260                    "tool_use_prompt_tokens": 0,
2261                    "reasoning_tokens": 0,
2262                }
2263            })
2264        );
2265
2266        let item: MultiTurnStreamItem<MockResponse> =
2267            serde_json::from_value(value).expect("deserialize completion call event");
2268        match item {
2269            MultiTurnStreamItem::CompletionCall(call_usage) => {
2270                assert_eq!(call_usage, CompletionCall::new(2, usage(3, 4)));
2271            }
2272            other => panic!("expected completion call event, got {other:?}"),
2273        }
2274
2275        let item: MultiTurnStreamItem<MockResponse> =
2276            MultiTurnStreamItem::CompletionCall(CompletionCall::new(3, Usage::new()));
2277        let value = serde_json::to_value(&item).expect("serialize missing usage event");
2278
2279        // Unreported usage serializes as a plain zero-valued object (Usage's
2280        // documented sentinel for missing provider metrics).
2281        assert_eq!(
2282            value,
2283            serde_json::json!({
2284                "type": "completionCall",
2285                "call_index": 3,
2286                "usage": {
2287                    "input_tokens": 0,
2288                    "output_tokens": 0,
2289                    "total_tokens": 0,
2290                    "cached_input_tokens": 0,
2291                    "cache_creation_input_tokens": 0,
2292                    "tool_use_prompt_tokens": 0,
2293                    "reasoning_tokens": 0,
2294                }
2295            })
2296        );
2297
2298        // Stream items serialized before the Option encoding was dropped used
2299        // `"usage": null`; they must still deserialize.
2300        let legacy: MultiTurnStreamItem<MockResponse> = serde_json::from_value(serde_json::json!({
2301            "type": "completionCall",
2302            "call_index": 3,
2303            "usage": null
2304        }))
2305        .expect("legacy null-usage event should deserialize");
2306        match legacy {
2307            MultiTurnStreamItem::CompletionCall(call) => {
2308                assert_eq!(call, CompletionCall::new(3, Usage::new()));
2309            }
2310            other => panic!("expected completion call event, got {other:?}"),
2311        }
2312    }
2313
2314    #[test]
2315    fn final_response_serializes_completion_calls_with_missing_usage() {
2316        let item: MultiTurnStreamItem<MockResponse> =
2317            MultiTurnStreamItem::final_response_with_completion_calls(
2318                OneOrMany::one(AssistantContent::text("done")),
2319                usage(3, 4),
2320                vec![
2321                    CompletionCall::new(0, Usage::new()),
2322                    CompletionCall::new(1, usage(3, 4)),
2323                ],
2324                None,
2325            );
2326
2327        if let MultiTurnStreamItem::FinalResponse(response) = &item {
2328            assert_eq!(response.requests(), 2);
2329        }
2330
2331        let value = serde_json::to_value(&item).expect("serialize final response");
2332
2333        assert_eq!(
2334            value.get("completion_calls"),
2335            Some(&serde_json::json!([
2336                {
2337                    "call_index": 0,
2338                    "usage": {
2339                        "input_tokens": 0,
2340                        "output_tokens": 0,
2341                        "total_tokens": 0,
2342                        "cached_input_tokens": 0,
2343                        "cache_creation_input_tokens": 0,
2344                        "tool_use_prompt_tokens": 0,
2345                        "reasoning_tokens": 0,
2346                    }
2347                },
2348                {
2349                    "call_index": 1,
2350                    "usage": {
2351                        "input_tokens": 3,
2352                        "output_tokens": 4,
2353                        "total_tokens": 7,
2354                        "cached_input_tokens": 0,
2355                        "cache_creation_input_tokens": 0,
2356                        "tool_use_prompt_tokens": 0,
2357                        "reasoning_tokens": 0,
2358                    }
2359                }
2360            ]))
2361        );
2362    }
2363
2364    fn streaming_text_then_final_model() -> MockCompletionModel {
2365        MockCompletionModel::from_stream_turns([[
2366            MockStreamEvent::text("hello"),
2367            MockStreamEvent::text(" world"),
2368            MockStreamEvent::final_response_with_total_tokens(3),
2369        ]])
2370    }
2371
2372    fn citation_metadata() -> serde_json::Value {
2373        serde_json::json!({
2374            "citations": [{
2375                "type": "web_search_result_location",
2376                "cited_text": "Claude Shannon was born in 1916.",
2377                "url": "https://example.com/shannon",
2378                "title": "Claude Shannon",
2379                "encrypted_index": "encrypted-reference"
2380            }]
2381        })
2382    }
2383
2384    fn streaming_cited_text_then_final_model() -> MockCompletionModel {
2385        MockCompletionModel::from_stream_turns([[
2386            MockStreamEvent::text_start(Some(citation_metadata())),
2387            MockStreamEvent::text("cited "),
2388            MockStreamEvent::text_start(None),
2389            MockStreamEvent::text("answer"),
2390            MockStreamEvent::final_response_with_total_tokens(3),
2391        ]])
2392    }
2393
2394    fn streaming_cited_text_then_tool_model() -> MockCompletionModel {
2395        MockCompletionModel::from_stream_turns([
2396            vec![
2397                MockStreamEvent::text_start(Some(citation_metadata())),
2398                MockStreamEvent::text("I need a tool. "),
2399                MockStreamEvent::tool_call(
2400                    "tool_call_1",
2401                    "add",
2402                    serde_json::json!({"x": 1, "y": 2}),
2403                )
2404                .with_call_id("call_1"),
2405                MockStreamEvent::final_response_with_total_tokens(4),
2406            ],
2407            vec![
2408                MockStreamEvent::text("done"),
2409                MockStreamEvent::final_response_with_total_tokens(6),
2410            ],
2411        ])
2412    }
2413
2414    fn streaming_final_only_model() -> MockCompletionModel {
2415        MockCompletionModel::from_stream_turns([[
2416            MockStreamEvent::final_response_with_total_tokens(1),
2417        ]])
2418    }
2419
2420    #[derive(Clone)]
2421    struct TerminateOnStreamFinish;
2422
2423    impl AgentHook<MockCompletionModel> for TerminateOnStreamFinish {
2424        async fn on_event(
2425            &self,
2426            _ctx: &HookContext,
2427            event: StepEvent<'_, MockCompletionModel>,
2428        ) -> Flow {
2429            match event {
2430                StepEvent::StreamResponseFinish { .. } => {
2431                    Flow::terminate("stop after completion call")
2432                }
2433                _ => Flow::cont(),
2434            }
2435        }
2436    }
2437
2438    type RecordedToolCallDelta = (String, String, Option<String>, String);
2439
2440    #[derive(Clone)]
2441    struct RepairDefaultApiHook;
2442
2443    impl AgentHook<MockCompletionModel> for RepairDefaultApiHook {
2444        async fn on_event(
2445            &self,
2446            _ctx: &HookContext,
2447            event: StepEvent<'_, MockCompletionModel>,
2448        ) -> Flow {
2449            match event {
2450                StepEvent::InvalidToolCall(context) => {
2451                    assert_eq!(context.tool_name, "default_api");
2452                    Flow::repair("add")
2453                }
2454                _ => Flow::cont(),
2455            }
2456        }
2457    }
2458
2459    #[derive(Clone)]
2460    struct RetryDefaultApiHook;
2461
2462    impl AgentHook<MockCompletionModel> for RetryDefaultApiHook {
2463        async fn on_event(
2464            &self,
2465            _ctx: &HookContext,
2466            event: StepEvent<'_, MockCompletionModel>,
2467        ) -> Flow {
2468            match event {
2469                StepEvent::InvalidToolCall(context) => {
2470                    assert_eq!(context.tool_name, "default_api");
2471                    if let Some(args) = context.args.as_deref() {
2472                        assert!(!args.is_empty());
2473                    }
2474                    Flow::retry("Use the add tool instead")
2475                }
2476                _ => Flow::cont(),
2477            }
2478        }
2479    }
2480
2481    #[derive(Clone)]
2482    struct SkipDefaultApiHook;
2483
2484    impl AgentHook<MockCompletionModel> for SkipDefaultApiHook {
2485        async fn on_event(
2486            &self,
2487            _ctx: &HookContext,
2488            event: StepEvent<'_, MockCompletionModel>,
2489        ) -> Flow {
2490            match event {
2491                StepEvent::InvalidToolCall(context) => {
2492                    assert_eq!(context.tool_name, "default_api");
2493                    Flow::skip("default_api was skipped")
2494                }
2495                _ => Flow::cont(),
2496            }
2497        }
2498    }
2499
2500    #[derive(Clone, Default)]
2501    struct RecordingInvalidToolCallHook {
2502        contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
2503    }
2504
2505    impl RecordingInvalidToolCallHook {
2506        fn observed(&self) -> Vec<InvalidToolCallContext> {
2507            self.contexts
2508                .lock()
2509                .expect("invalid tool context records mutex was poisoned")
2510                .clone()
2511        }
2512    }
2513
2514    impl AgentHook<MockCompletionModel> for RecordingInvalidToolCallHook {
2515        async fn on_event(
2516            &self,
2517            _ctx: &HookContext,
2518            event: StepEvent<'_, MockCompletionModel>,
2519        ) -> Flow {
2520            match event {
2521                StepEvent::InvalidToolCall(context) => {
2522                    self.contexts
2523                        .lock()
2524                        .expect("invalid tool context records mutex was poisoned")
2525                        .push(context.clone());
2526                    Flow::fail()
2527                }
2528                _ => Flow::cont(),
2529            }
2530        }
2531    }
2532
2533    #[derive(Clone, Default)]
2534    struct RecordingToolCallDeltaHook {
2535        deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
2536    }
2537
2538    impl RecordingToolCallDeltaHook {
2539        fn observed(&self) -> Vec<RecordedToolCallDelta> {
2540            self.deltas
2541                .lock()
2542                .expect("tool call delta hook records mutex was poisoned")
2543                .clone()
2544        }
2545    }
2546
2547    impl AgentHook<MockCompletionModel> for RecordingToolCallDeltaHook {
2548        async fn on_event(
2549            &self,
2550            _ctx: &HookContext,
2551            event: StepEvent<'_, MockCompletionModel>,
2552        ) -> Flow {
2553            match event {
2554                StepEvent::ToolCallDelta {
2555                    tool_call_id,
2556                    internal_call_id,
2557                    tool_name,
2558                    delta,
2559                } => {
2560                    let record = (
2561                        tool_call_id.to_string(),
2562                        internal_call_id.to_string(),
2563                        tool_name.map(str::to_string),
2564                        delta.to_string(),
2565                    );
2566                    self.deltas
2567                        .lock()
2568                        .expect("tool call delta hook records mutex was poisoned")
2569                        .push(record);
2570                    Flow::cont()
2571                }
2572                _ => Flow::cont(),
2573            }
2574        }
2575    }
2576
2577    #[derive(Clone, Default)]
2578    struct RecordingTextDeltaHook {
2579        deltas: Arc<Mutex<Vec<(String, String)>>>,
2580    }
2581
2582    impl RecordingTextDeltaHook {
2583        fn observed(&self) -> Vec<(String, String)> {
2584            self.deltas
2585                .lock()
2586                .expect("text delta hook records mutex was poisoned")
2587                .clone()
2588        }
2589    }
2590
2591    impl AgentHook<MockCompletionModel> for RecordingTextDeltaHook {
2592        async fn on_event(
2593            &self,
2594            _ctx: &HookContext,
2595            event: StepEvent<'_, MockCompletionModel>,
2596        ) -> Flow {
2597            match event {
2598                StepEvent::TextDelta { delta, aggregated } => {
2599                    let record = (delta.to_string(), aggregated.to_string());
2600                    self.deltas
2601                        .lock()
2602                        .expect("text delta hook records mutex was poisoned")
2603                        .push(record);
2604                    Flow::cont()
2605                }
2606                _ => Flow::cont(),
2607            }
2608        }
2609    }
2610
2611    #[derive(Clone)]
2612    struct RecordingTextAndSkipInvalidToolHook {
2613        text: RecordingTextDeltaHook,
2614    }
2615
2616    impl AgentHook<MockCompletionModel> for RecordingTextAndSkipInvalidToolHook {
2617        async fn on_event(
2618            &self,
2619            _ctx: &HookContext,
2620            event: StepEvent<'_, MockCompletionModel>,
2621        ) -> Flow {
2622            match event {
2623                event @ StepEvent::TextDelta { .. } => self.text.on_event(_ctx, event).await,
2624                event @ StepEvent::InvalidToolCall(_) => {
2625                    SkipDefaultApiHook.on_event(_ctx, event).await
2626                }
2627                _ => Flow::cont(),
2628            }
2629        }
2630    }
2631
2632    #[derive(Clone)]
2633    struct RecordingTextAndRetryInvalidToolHook {
2634        text: RecordingTextDeltaHook,
2635    }
2636
2637    impl AgentHook<MockCompletionModel> for RecordingTextAndRetryInvalidToolHook {
2638        async fn on_event(
2639            &self,
2640            _ctx: &HookContext,
2641            event: StepEvent<'_, MockCompletionModel>,
2642        ) -> Flow {
2643            match event {
2644                event @ StepEvent::TextDelta { .. } => self.text.on_event(_ctx, event).await,
2645                event @ StepEvent::InvalidToolCall(_) => {
2646                    RetryDefaultApiHook.on_event(_ctx, event).await
2647                }
2648                _ => Flow::cont(),
2649            }
2650        }
2651    }
2652
2653    #[derive(Clone)]
2654    struct RecordingDeltaAndRetryInvalidToolHook {
2655        delta: RecordingToolCallDeltaHook,
2656    }
2657
2658    impl AgentHook<MockCompletionModel> for RecordingDeltaAndRetryInvalidToolHook {
2659        async fn on_event(
2660            &self,
2661            _ctx: &HookContext,
2662            event: StepEvent<'_, MockCompletionModel>,
2663        ) -> Flow {
2664            match event {
2665                event @ StepEvent::ToolCallDelta { .. } => self.delta.on_event(_ctx, event).await,
2666                event @ StepEvent::InvalidToolCall(_) => {
2667                    RetryDefaultApiHook.on_event(_ctx, event).await
2668                }
2669                _ => Flow::cont(),
2670            }
2671        }
2672    }
2673
2674    #[derive(Clone)]
2675    struct RecordingDeltaAndSkipInvalidToolHook {
2676        delta: RecordingToolCallDeltaHook,
2677    }
2678
2679    impl AgentHook<MockCompletionModel> for RecordingDeltaAndSkipInvalidToolHook {
2680        async fn on_event(
2681            &self,
2682            _ctx: &HookContext,
2683            event: StepEvent<'_, MockCompletionModel>,
2684        ) -> Flow {
2685            match event {
2686                event @ StepEvent::ToolCallDelta { .. } => self.delta.on_event(_ctx, event).await,
2687                event @ StepEvent::InvalidToolCall(_) => {
2688                    SkipDefaultApiHook.on_event(_ctx, event).await
2689                }
2690                _ => Flow::cont(),
2691            }
2692        }
2693    }
2694
2695    #[derive(Clone, Default)]
2696    struct TerminatingToolCallDeltaHook {
2697        deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
2698    }
2699
2700    impl TerminatingToolCallDeltaHook {
2701        fn observed(&self) -> Vec<RecordedToolCallDelta> {
2702            self.deltas
2703                .lock()
2704                .expect("tool call delta hook records mutex was poisoned")
2705                .clone()
2706        }
2707    }
2708
2709    impl AgentHook<MockCompletionModel> for TerminatingToolCallDeltaHook {
2710        async fn on_event(
2711            &self,
2712            _ctx: &HookContext,
2713            event: StepEvent<'_, MockCompletionModel>,
2714        ) -> Flow {
2715            match event {
2716                StepEvent::ToolCallDelta {
2717                    tool_call_id,
2718                    internal_call_id,
2719                    tool_name,
2720                    delta,
2721                } => {
2722                    let record = (
2723                        tool_call_id.to_string(),
2724                        internal_call_id.to_string(),
2725                        tool_name.map(str::to_string),
2726                        delta.to_string(),
2727                    );
2728                    self.deltas
2729                        .lock()
2730                        .expect("tool call delta hook records mutex was poisoned")
2731                        .push(record);
2732                    Flow::terminate("stop on tool call delta")
2733                }
2734                _ => Flow::cont(),
2735            }
2736        }
2737    }
2738
2739    fn text_metadata(content: &OneOrMany<AssistantContent>) -> Option<&serde_json::Value> {
2740        content.iter().find_map(|item| match item {
2741            AssistantContent::Text(text) => text.additional_params.as_ref(),
2742            _ => None,
2743        })
2744    }
2745
2746    #[tokio::test]
2747    async fn stream_prompt_continues_after_tool_call_turn() {
2748        let model = streaming_tool_then_text_model();
2749        let recorded = model.clone();
2750        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2751        let empty_history: &[Message] = &[];
2752
2753        let mut stream = agent
2754            .stream_prompt("do tool work")
2755            .history(empty_history)
2756            .max_turns(3)
2757            .await;
2758        let mut saw_tool_call = false;
2759        let mut saw_tool_result = false;
2760        let mut saw_final_response = false;
2761        let mut final_text = String::new();
2762        let mut final_response_text = None;
2763        let mut final_history = None;
2764
2765        while let Some(item) = stream.next().await {
2766            match item {
2767                Ok(MultiTurnStreamItem::StreamAssistantItem(
2768                    StreamedAssistantContent::ToolCall { .. },
2769                )) => {
2770                    saw_tool_call = true;
2771                }
2772                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2773                    ..
2774                })) => {
2775                    saw_tool_result = true;
2776                }
2777                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
2778                    text,
2779                ))) => {
2780                    final_text.push_str(&text.text);
2781                }
2782                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
2783                    saw_final_response = true;
2784                    final_response_text = Some(res.output().to_owned());
2785                    final_history = res.messages().map(|history| history.to_vec());
2786                    break;
2787                }
2788                Ok(_) => {}
2789                Err(err) => panic!("unexpected streaming error: {err:?}"),
2790            }
2791        }
2792
2793        assert!(saw_tool_call);
2794        assert!(saw_tool_result);
2795        assert!(saw_final_response);
2796        assert_eq!(final_text, "done");
2797        assert_eq!(final_response_text.as_deref(), Some("done"));
2798        let history = final_history.expect("expected final response history");
2799        assert!(history.iter().any(|message| matches!(
2800            message,
2801            Message::Assistant { content, .. }
2802                if content.iter().any(|item| matches!(
2803                    item,
2804                    AssistantContent::Text(text) if text.text == "done"
2805                ))
2806        )));
2807        let requests = recorded.requests();
2808        assert_eq!(requests.len(), 2);
2809        assert!(validate_follow_up_tool_history(&requests[1]).is_ok());
2810    }
2811
2812    /// `StreamingPromptRequest::tool_concurrency` reaches the runner: two
2813    /// barrier-synchronized tools in a streamed turn only finish if they run
2814    /// concurrently. At `tool_concurrency(2)` the stream completes; sequential
2815    /// execution would block on the first tool forever, so the timeout asserts
2816    /// the public builder actually enables concurrency on the streaming path.
2817    #[tokio::test]
2818    async fn streaming_prompt_request_tool_concurrency_runs_tools_concurrently() {
2819        let barrier = Arc::new(tokio::sync::Barrier::new(2));
2820        let model = MockCompletionModel::from_stream_turns([
2821            vec![
2822                MockStreamEvent::tool_call("b1", "barrier_tool", serde_json::json!({})),
2823                MockStreamEvent::tool_call("b2", "barrier_tool", serde_json::json!({})),
2824                MockStreamEvent::final_response_with_total_tokens(0),
2825            ],
2826            vec![
2827                MockStreamEvent::text("done"),
2828                MockStreamEvent::final_response_with_total_tokens(0),
2829            ],
2830        ]);
2831        let agent = AgentBuilder::new(model)
2832            .tool(MockBarrierTool::new(barrier))
2833            .build();
2834
2835        let drive = async {
2836            let mut stream = agent
2837                .stream_prompt("hit the barrier twice")
2838                .max_turns(3)
2839                .tool_concurrency(2)
2840                .await;
2841            while let Some(item) = stream.next().await {
2842                item.unwrap_or_else(|err| panic!("unexpected streaming error: {err:?}"));
2843            }
2844        };
2845
2846        tokio::time::timeout(Duration::from_secs(5), drive)
2847            .await
2848            .expect("streamed tools must run concurrently, not deadlock at the barrier");
2849    }
2850
2851    /// The streaming driver threads the per-call `ToolCallExtensions` to executed
2852    /// tools, exactly like the blocking path.
2853    #[tokio::test]
2854    async fn tool_extensions_reach_tool_through_streaming_loop() {
2855        let model = MockCompletionModel::from_stream_turns([
2856            vec![
2857                MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
2858                    .with_call_id("call_1"),
2859                MockStreamEvent::final_response_with_total_tokens(4),
2860            ],
2861            vec![
2862                MockStreamEvent::text("done"),
2863                MockStreamEvent::final_response_with_total_tokens(6),
2864            ],
2865        ]);
2866        let probe = MockExtensionsProbeTool::default();
2867        let agent = AgentBuilder::new(model).tool(probe.clone()).build();
2868        let empty_history: &[Message] = &[];
2869
2870        let mut extensions = ToolCallExtensions::new();
2871        extensions.insert(SessionId("xyz-789".to_string()));
2872
2873        let mut stream = agent
2874            .stream_prompt("do tool work")
2875            .tool_extensions(extensions)
2876            .history(empty_history)
2877            .max_turns(3)
2878            .await;
2879
2880        while let Some(item) = stream.next().await {
2881            match item {
2882                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
2883                Err(err) => panic!("unexpected streaming error: {err:?}"),
2884                Ok(_) => {}
2885            }
2886        }
2887
2888        assert_eq!(probe.observed().as_deref(), Some("session:xyz-789"));
2889    }
2890
2891    /// Streaming counterpart of the blocking empty-extensions default: with no
2892    /// `.tool_extensions(..)`, the tool still runs with empty extensions
2893    /// (observing `no-session`), not a stale value.
2894    #[tokio::test]
2895    async fn streaming_tool_runs_with_empty_context_when_none_supplied() {
2896        let model = MockCompletionModel::from_stream_turns([
2897            vec![
2898                MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
2899                    .with_call_id("call_1"),
2900                MockStreamEvent::final_response_with_total_tokens(4),
2901            ],
2902            vec![
2903                MockStreamEvent::text("done"),
2904                MockStreamEvent::final_response_with_total_tokens(6),
2905            ],
2906        ]);
2907        let probe = MockExtensionsProbeTool::default();
2908        let agent = AgentBuilder::new(model).tool(probe.clone()).build();
2909        let empty_history: &[Message] = &[];
2910
2911        let mut stream = agent
2912            .stream_prompt("do tool work")
2913            .history(empty_history)
2914            .max_turns(3)
2915            .await;
2916
2917        while let Some(item) = stream.next().await {
2918            match item {
2919                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
2920                Err(err) => panic!("unexpected streaming error: {err:?}"),
2921                Ok(_) => {}
2922            }
2923        }
2924
2925        assert_eq!(probe.observed().as_deref(), Some("no-session"));
2926    }
2927
2928    #[tokio::test]
2929    async fn unknown_tool_call_fails_before_streaming_second_request() {
2930        let model = MockCompletionModel::from_stream_turns([
2931            vec![
2932                MockStreamEvent::tool_call(
2933                    "tool_call_1",
2934                    "default_api",
2935                    serde_json::json!({"x": 1, "y": 2}),
2936                ),
2937                MockStreamEvent::final_response_with_total_tokens(4),
2938            ],
2939            vec![
2940                MockStreamEvent::text("should not be requested"),
2941                MockStreamEvent::final_response_with_total_tokens(6),
2942            ],
2943        ]);
2944        let recorded = model.clone();
2945        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2946
2947        let mut stream = agent
2948            .stream_prompt("use the tool")
2949            .add_hook(PanicOnUnknownToolHook)
2950            .max_turns(3)
2951            .await;
2952        let mut saw_tool_call = false;
2953        let mut error = None;
2954
2955        while let Some(item) = stream.next().await {
2956            match item {
2957                Ok(MultiTurnStreamItem::StreamAssistantItem(
2958                    StreamedAssistantContent::ToolCall { .. },
2959                )) => {
2960                    saw_tool_call = true;
2961                }
2962                Ok(_) => {}
2963                Err(err) => {
2964                    error = Some(err);
2965                    break;
2966                }
2967            }
2968        }
2969
2970        assert!(!saw_tool_call);
2971        let error = error.expect("unknown model-emitted tool should fail");
2972        match error {
2973            StreamingError::Prompt(err) => match *err {
2974                PromptError::UnknownToolCall {
2975                    tool_name,
2976                    available_tools,
2977                    allowed_tools,
2978                    chat_history,
2979                } => {
2980                    assert_eq!(tool_name, "default_api");
2981                    assert_eq!(available_tools, vec!["add".to_string()]);
2982                    assert_eq!(allowed_tools, vec!["add".to_string()]);
2983                    assert!(history_contains_tool_call(&chat_history, "default_api"));
2984                }
2985                other => panic!("expected UnknownToolCall, got {other:?}"),
2986            },
2987            other => panic!("expected prompt streaming error, got {other:?}"),
2988        }
2989        assert_eq!(recorded.request_count(), 1);
2990    }
2991
2992    #[tokio::test]
2993    async fn invalid_tool_call_hook_can_repair_streaming_tool_name() {
2994        let model = MockCompletionModel::from_stream_turns([
2995            vec![
2996                MockStreamEvent::tool_call(
2997                    "tool_call_1",
2998                    "default_api",
2999                    serde_json::json!({"x": 2, "y": 3}),
3000                ),
3001                MockStreamEvent::final_response_with_total_tokens(4),
3002            ],
3003            vec![
3004                MockStreamEvent::text("done"),
3005                MockStreamEvent::final_response_with_total_tokens(6),
3006            ],
3007        ]);
3008        let recorded = model.clone();
3009        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3010
3011        let mut stream = agent
3012            .stream_prompt("use the tool")
3013            .add_hook(RepairDefaultApiHook)
3014            .max_turns(3)
3015            .history(Vec::<Message>::new())
3016            .await;
3017        let mut saw_repaired_tool_call = false;
3018        let mut saw_tool_result = false;
3019        let mut final_response_text = None;
3020
3021        while let Some(item) = stream.next().await {
3022            match item {
3023                Ok(MultiTurnStreamItem::StreamAssistantItem(
3024                    StreamedAssistantContent::ToolCall { tool_call, .. },
3025                )) => {
3026                    assert_eq!(tool_call.function.name, "add");
3027                    saw_repaired_tool_call = true;
3028                }
3029                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3030                    tool_result,
3031                    ..
3032                })) => {
3033                    assert!(tool_result.content.iter().any(|content| {
3034                        matches!(
3035                            content,
3036                            ToolResultContent::Text(text) if text.text == "5"
3037                        )
3038                    }));
3039                    saw_tool_result = true;
3040                }
3041                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3042                    final_response_text = Some(response.output().to_string());
3043                    break;
3044                }
3045                Ok(_) => {}
3046                Err(err) => panic!("unexpected streaming error: {err:?}"),
3047            }
3048        }
3049
3050        assert!(saw_repaired_tool_call);
3051        assert!(saw_tool_result);
3052        assert_eq!(final_response_text.as_deref(), Some("done"));
3053        assert_eq!(recorded.request_count(), 2);
3054    }
3055
3056    #[tokio::test]
3057    async fn invalid_tool_call_context_uses_completed_streaming_tool_call_provider_id() {
3058        let invalid_hook = RecordingInvalidToolCallHook::default();
3059        let model = MockCompletionModel::from_stream_turns([
3060            vec![
3061                MockStreamEvent::tool_call(
3062                    "tool_call_1",
3063                    "default_api",
3064                    serde_json::json!({"x": 2, "y": 3}),
3065                )
3066                .with_call_id("provider_call_1"),
3067                MockStreamEvent::final_response_with_total_tokens(4),
3068            ],
3069            vec![
3070                MockStreamEvent::text("should not be requested"),
3071                MockStreamEvent::final_response_with_total_tokens(6),
3072            ],
3073        ]);
3074        let recorded = model.clone();
3075        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3076
3077        let mut stream = agent
3078            .stream_prompt("use the tool")
3079            .add_hook(invalid_hook.clone())
3080            .max_turns(3)
3081            .await;
3082        let mut error = None;
3083
3084        while let Some(item) = stream.next().await {
3085            if let Err(err) = item {
3086                error = Some(err);
3087                break;
3088            }
3089        }
3090
3091        assert!(error.is_some(), "invalid tool should fail");
3092        assert_eq!(recorded.request_count(), 1);
3093        let contexts = invalid_hook.observed();
3094        assert_eq!(contexts.len(), 1);
3095        let context = &contexts[0];
3096        assert_eq!(context.tool_name, "default_api");
3097        assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
3098        assert!(context.internal_call_id.is_some());
3099        assert!(context.is_streaming);
3100    }
3101
3102    #[tokio::test]
3103    async fn invalid_tool_call_hook_skip_emits_streaming_tool_result() {
3104        let add_calls = Arc::new(AtomicU32::new(0));
3105        let model = MockCompletionModel::from_stream_turns([
3106            vec![
3107                MockStreamEvent::tool_call(
3108                    "tool_call_1",
3109                    "default_api",
3110                    serde_json::json!({"x": 2, "y": 3}),
3111                )
3112                .with_call_id("call_1"),
3113                MockStreamEvent::final_response_with_total_tokens(4),
3114            ],
3115            vec![
3116                MockStreamEvent::text("continued"),
3117                MockStreamEvent::final_response_with_total_tokens(6),
3118            ],
3119        ]);
3120        let recorded = model.clone();
3121        let agent = AgentBuilder::new(model)
3122            .tool(CountingAddTool {
3123                calls: add_calls.clone(),
3124            })
3125            .build();
3126
3127        let mut stream = agent
3128            .stream_prompt("use the tool")
3129            .add_hook(SkipDefaultApiHook)
3130            .max_turns(3)
3131            .history(Vec::<Message>::new())
3132            .await;
3133        let mut skipped_tool_result = None;
3134        let mut final_response_text = None;
3135
3136        while let Some(item) = stream.next().await {
3137            match item {
3138                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3139                    tool_result,
3140                    internal_call_id,
3141                })) => {
3142                    assert!(!internal_call_id.is_empty());
3143                    skipped_tool_result = Some(tool_result);
3144                }
3145                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3146                    final_response_text = Some(response.output().to_string());
3147                    break;
3148                }
3149                Ok(_) => {}
3150                Err(err) => panic!("unexpected streaming error: {err:?}"),
3151            }
3152        }
3153
3154        let skipped_tool_result =
3155            skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
3156        assert_eq!(skipped_tool_result.id, "tool_call_1");
3157        assert_eq!(skipped_tool_result.call_id.as_deref(), Some("call_1"));
3158        assert!(skipped_tool_result.content.iter().any(|content| matches!(
3159            content,
3160            ToolResultContent::Text(text) if text.text == "default_api was skipped"
3161        )));
3162        assert_eq!(final_response_text.as_deref(), Some("continued"));
3163        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3164
3165        let requests = recorded.requests();
3166        assert_eq!(requests.len(), 2);
3167        let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3168        assert!(matches!(
3169            follow_up_history.get(2),
3170            Some(Message::User { content })
3171                if content.iter().any(|item| matches!(
3172                    item,
3173                    UserContent::ToolResult(result)
3174                        if result.id == "tool_call_1"
3175                            && result.content.iter().any(|content| matches!(
3176                                content,
3177                                ToolResultContent::Text(text)
3178                                    if text.text == "default_api was skipped"
3179                            ))
3180                ))
3181        ));
3182    }
3183
3184    #[tokio::test]
3185    async fn invalid_tool_call_hook_retries_mixed_streaming_turn_without_executing_valid_call() {
3186        let add_calls = Arc::new(AtomicU32::new(0));
3187        let model = MockCompletionModel::from_stream_turns([
3188            vec![
3189                MockStreamEvent::text("checking "),
3190                MockStreamEvent::tool_call(
3191                    "tool_call_1",
3192                    "add",
3193                    serde_json::json!({"x": 2, "y": 3}),
3194                )
3195                .with_call_id("call_1"),
3196                MockStreamEvent::tool_call(
3197                    "tool_call_2",
3198                    "default_api",
3199                    serde_json::json!({"x": 4, "y": 5}),
3200                )
3201                .with_call_id("call_2"),
3202                MockStreamEvent::final_response_with_total_tokens(4),
3203            ],
3204            vec![
3205                MockStreamEvent::text("retried"),
3206                MockStreamEvent::final_response_with_total_tokens(6),
3207            ],
3208        ]);
3209        let recorded = model.clone();
3210        let agent = AgentBuilder::new(model)
3211            .tool(CountingAddTool {
3212                calls: add_calls.clone(),
3213            })
3214            .build();
3215
3216        let mut stream = agent
3217            .stream_prompt("use the tool")
3218            .add_hook(RetryDefaultApiHook)
3219            .max_turns(3)
3220            .history(Vec::<Message>::new())
3221            .max_invalid_tool_call_retries(1)
3222            .await;
3223        let mut completion_call_events = Vec::new();
3224        let mut final_response_text = None;
3225        let mut final_response_usage = Usage::new();
3226        let mut final_completion_calls = Vec::new();
3227
3228        while let Some(item) = stream.next().await {
3229            match item {
3230                Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
3231                    completion_call_events.push(completion_call);
3232                }
3233                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3234                    final_response_text = Some(response.output().to_string());
3235                    final_response_usage = response.usage();
3236                    final_completion_calls = response.completion_calls().to_vec();
3237                    break;
3238                }
3239                Ok(_) => {}
3240                Err(err) => panic!("unexpected streaming error: {err:?}"),
3241            }
3242        }
3243
3244        assert_eq!(final_response_text.as_deref(), Some("retried"));
3245        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3246        let mut first_usage = Usage::new();
3247        first_usage.total_tokens = 4;
3248        let mut second_usage = Usage::new();
3249        second_usage.total_tokens = 6;
3250        let expected_completion_calls = vec![
3251            CompletionCall::new(0, first_usage),
3252            CompletionCall::new(1, second_usage),
3253        ];
3254        assert_eq!(completion_call_events, expected_completion_calls);
3255        assert_eq!(final_completion_calls, expected_completion_calls);
3256        assert_eq!(final_response_usage.total_tokens, 10);
3257
3258        let requests = recorded.requests();
3259        assert_eq!(requests.len(), 2);
3260        let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3261        assert_eq!(retry_history.len(), 3);
3262        assert!(matches!(
3263            retry_history.get(1),
3264            Some(Message::Assistant { content, .. })
3265                if content.iter().any(|item| matches!(
3266                    item,
3267                    AssistantContent::Text(text) if text.text == "checking "
3268                ))
3269                    && content.iter().any(|item| matches!(
3270                        item,
3271                        AssistantContent::ToolCall(tool_call)
3272                            if tool_call.id == "tool_call_1"
3273                                && tool_call.function.name == "add"
3274                    ))
3275                    && content.iter().any(|item| matches!(
3276                        item,
3277                        AssistantContent::ToolCall(tool_call)
3278                            if tool_call.id == "tool_call_2"
3279                                && tool_call.function.name == "default_api"
3280                    ))
3281        ));
3282        assert!(matches!(
3283            retry_history.get(2),
3284            Some(Message::User { content })
3285                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3286                    && content.iter().any(|item| matches!(
3287                        item,
3288                        UserContent::ToolResult(result)
3289                            if result.id == "tool_call_1"
3290                                && result.content.iter().any(|content| matches!(
3291                                    content,
3292                                    ToolResultContent::Text(text)
3293                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3294                                ))
3295                    ))
3296                    && content.iter().any(|item| matches!(
3297                        item,
3298                        UserContent::ToolResult(result)
3299                            if result.id == "tool_call_2"
3300                                && result.content.iter().any(|content| matches!(
3301                                    content,
3302                                    ToolResultContent::Text(text)
3303                                        if text.text == "Use the add tool instead"
3304                                ))
3305                    ))
3306        ));
3307    }
3308
3309    #[tokio::test]
3310    async fn invalid_tool_call_hook_skips_mixed_streaming_turn_without_executing_valid_call() {
3311        let add_calls = Arc::new(AtomicU32::new(0));
3312        let model = MockCompletionModel::from_stream_turns([
3313            vec![
3314                MockStreamEvent::text("checking "),
3315                MockStreamEvent::tool_call(
3316                    "tool_call_1",
3317                    "add",
3318                    serde_json::json!({"x": 2, "y": 3}),
3319                )
3320                .with_call_id("call_1"),
3321                MockStreamEvent::tool_call(
3322                    "tool_call_2",
3323                    "default_api",
3324                    serde_json::json!({"x": 4, "y": 5}),
3325                )
3326                .with_call_id("call_2"),
3327                MockStreamEvent::final_response_with_total_tokens(4),
3328            ],
3329            vec![
3330                MockStreamEvent::text("continued"),
3331                MockStreamEvent::final_response_with_total_tokens(6),
3332            ],
3333        ]);
3334        let recorded = model.clone();
3335        let agent = AgentBuilder::new(model)
3336            .tool(CountingAddTool {
3337                calls: add_calls.clone(),
3338            })
3339            .build();
3340
3341        let mut stream = agent
3342            .stream_prompt("use the tool")
3343            .add_hook(SkipDefaultApiHook)
3344            .max_turns(3)
3345            .history(Vec::<Message>::new())
3346            .await;
3347        let mut skipped_tool_result = None;
3348        let mut final_response_text = None;
3349
3350        while let Some(item) = stream.next().await {
3351            match item {
3352                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3353                    tool_result,
3354                    ..
3355                })) => {
3356                    skipped_tool_result = Some(tool_result);
3357                }
3358                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3359                    final_response_text = Some(response.output().to_string());
3360                    break;
3361                }
3362                Ok(_) => {}
3363                Err(err) => panic!("unexpected streaming error: {err:?}"),
3364            }
3365        }
3366
3367        let skipped_tool_result =
3368            skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
3369        assert_eq!(skipped_tool_result.id, "tool_call_2");
3370        assert_eq!(skipped_tool_result.call_id.as_deref(), Some("call_2"));
3371        assert_eq!(final_response_text.as_deref(), Some("continued"));
3372        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3373
3374        let requests = recorded.requests();
3375        assert_eq!(requests.len(), 2);
3376        let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3377        assert_eq!(follow_up_history.len(), 3);
3378        assert!(matches!(
3379            follow_up_history.get(1),
3380            Some(Message::Assistant { content, .. })
3381                if content.iter().any(|item| matches!(
3382                    item,
3383                    AssistantContent::Text(text) if text.text == "checking "
3384                ))
3385                    && content.iter().any(|item| matches!(
3386                        item,
3387                        AssistantContent::ToolCall(tool_call)
3388                            if tool_call.id == "tool_call_1"
3389                                && tool_call.function.name == "add"
3390                    ))
3391                    && content.iter().any(|item| matches!(
3392                        item,
3393                        AssistantContent::ToolCall(tool_call)
3394                            if tool_call.id == "tool_call_2"
3395                                && tool_call.function.name == "default_api"
3396                    ))
3397        ));
3398        assert!(matches!(
3399            follow_up_history.get(2),
3400            Some(Message::User { content })
3401                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3402                    && content.iter().any(|item| matches!(
3403                        item,
3404                        UserContent::ToolResult(result)
3405                            if result.id == "tool_call_1"
3406                                && result.call_id.as_deref() == Some("call_1")
3407                                && result.content.iter().any(|content| matches!(
3408                                    content,
3409                                    ToolResultContent::Text(text)
3410                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3411                                ))
3412                    ))
3413                    && content.iter().any(|item| matches!(
3414                        item,
3415                        UserContent::ToolResult(result)
3416                            if result.id == "tool_call_2"
3417                                && result.call_id.as_deref() == Some("call_2")
3418                                && result.content.iter().any(|content| matches!(
3419                                    content,
3420                                    ToolResultContent::Text(text)
3421                                        if text.text == "default_api was skipped"
3422                                ))
3423            ))
3424        ));
3425    }
3426
3427    #[tokio::test]
3428    async fn invalid_completed_tool_call_skip_preserves_streaming_reasoning_history() {
3429        let model = MockCompletionModel::from_stream_turns([
3430            vec![
3431                MockStreamEvent::text("checking "),
3432                MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
3433                MockStreamEvent::tool_call(
3434                    "tool_call_1",
3435                    "default_api",
3436                    serde_json::json!({"x": 2, "y": 3}),
3437                ),
3438                MockStreamEvent::final_response_with_total_tokens(4),
3439            ],
3440            vec![
3441                MockStreamEvent::text("continued"),
3442                MockStreamEvent::final_response_with_total_tokens(6),
3443            ],
3444        ]);
3445        let recorded = model.clone();
3446        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3447
3448        let mut stream = agent
3449            .stream_prompt("use the tool")
3450            .add_hook(SkipDefaultApiHook)
3451            .max_turns(3)
3452            .history(Vec::<Message>::new())
3453            .await;
3454
3455        while let Some(item) = stream.next().await {
3456            match item {
3457                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3458                Ok(_) => {}
3459                Err(err) => panic!("unexpected streaming error: {err:?}"),
3460            }
3461        }
3462
3463        let requests = recorded.requests();
3464        assert_eq!(requests.len(), 2);
3465        let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3466        assert!(history_contains_text(&follow_up_history, "checking "));
3467        assert!(assistant_reasoning_precedes_tool_call(
3468            &follow_up_history,
3469            "reasoned step",
3470            "default_api"
3471        ));
3472        assert!(
3473            assistant_reasoning_precedes_text_and_tool_call(
3474                &follow_up_history,
3475                "reasoned step",
3476                "checking ",
3477                "default_api"
3478            ),
3479            "{follow_up_history:?}"
3480        );
3481    }
3482
3483    #[tokio::test]
3484    async fn invalid_name_delta_retry_preserves_streaming_reasoning_history() {
3485        let model = MockCompletionModel::from_stream_turns([
3486            vec![
3487                MockStreamEvent::reasoning_delta(Some("rs_1"), "delta reason"),
3488                MockStreamEvent::tool_call_arguments_delta(
3489                    "tool_call_1",
3490                    "internal_1",
3491                    r#"{"x":2,"y":3}"#,
3492                ),
3493                MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3494                MockStreamEvent::final_response_with_total_tokens(4),
3495            ],
3496            vec![
3497                MockStreamEvent::text("retried"),
3498                MockStreamEvent::final_response_with_total_tokens(6),
3499            ],
3500        ]);
3501        let recorded = model.clone();
3502        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3503
3504        let mut stream = agent
3505            .stream_prompt("use the tool")
3506            .add_hook(RetryDefaultApiHook)
3507            .max_turns(3)
3508            .history(Vec::<Message>::new())
3509            .max_invalid_tool_call_retries(1)
3510            .await;
3511
3512        while let Some(item) = stream.next().await {
3513            match item {
3514                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3515                Ok(_) => {}
3516                Err(err) => panic!("unexpected streaming error: {err:?}"),
3517            }
3518        }
3519
3520        let requests = recorded.requests();
3521        assert_eq!(requests.len(), 2);
3522        let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3523        assert!(assistant_reasoning_precedes_tool_call(
3524            &retry_history,
3525            "delta reason",
3526            "default_api"
3527        ));
3528    }
3529
3530    #[tokio::test]
3531    async fn invalid_tool_call_hook_skip_resets_streaming_text_delta_state() {
3532        let text_hook = RecordingTextDeltaHook::default();
3533        let model = MockCompletionModel::from_stream_turns([
3534            vec![
3535                MockStreamEvent::text("stale "),
3536                MockStreamEvent::tool_call(
3537                    "tool_call_1",
3538                    "default_api",
3539                    serde_json::json!({"x": 2, "y": 3}),
3540                ),
3541                MockStreamEvent::final_response_with_total_tokens(4),
3542            ],
3543            vec![
3544                MockStreamEvent::text("fresh"),
3545                MockStreamEvent::final_response_with_total_tokens(6),
3546            ],
3547        ]);
3548        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3549
3550        let mut stream = agent
3551            .stream_prompt("use the tool")
3552            .add_hook(RecordingTextAndSkipInvalidToolHook {
3553                text: text_hook.clone(),
3554            })
3555            .max_turns(3)
3556            .history(Vec::<Message>::new())
3557            .await;
3558
3559        while let Some(item) = stream.next().await {
3560            match item {
3561                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3562                Ok(_) => {}
3563                Err(err) => panic!("unexpected streaming error: {err:?}"),
3564            }
3565        }
3566
3567        assert_eq!(
3568            text_hook.observed(),
3569            vec![
3570                ("stale ".to_string(), "stale ".to_string()),
3571                ("fresh".to_string(), "fresh".to_string()),
3572            ]
3573        );
3574    }
3575
3576    #[tokio::test]
3577    async fn invalid_tool_call_delta_retry_uses_structured_tool_feedback() {
3578        let delta_hook = RecordingToolCallDeltaHook::default();
3579        let add_calls = Arc::new(AtomicU32::new(0));
3580        let model = MockCompletionModel::from_stream_turns([
3581            vec![
3582                MockStreamEvent::text("checking "),
3583                MockStreamEvent::reasoning_delta(Some("rs_1"), "diagnostic reason"),
3584                MockStreamEvent::tool_call(
3585                    "tool_call_0",
3586                    "add",
3587                    serde_json::json!({"x": 1, "y": 2}),
3588                )
3589                .with_call_id("call_0"),
3590                MockStreamEvent::tool_call_arguments_delta(
3591                    "tool_call_1",
3592                    "internal_1",
3593                    r#"{"x":2,"y":3}"#,
3594                ),
3595                MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3596                MockStreamEvent::final_response_with_total_tokens(4),
3597            ],
3598            vec![
3599                MockStreamEvent::text("retried"),
3600                MockStreamEvent::final_response_with_total_tokens(6),
3601            ],
3602        ]);
3603        let recorded = model.clone();
3604        let agent = AgentBuilder::new(model)
3605            .tool(CountingAddTool {
3606                calls: add_calls.clone(),
3607            })
3608            .build();
3609
3610        let mut stream = agent
3611            .stream_prompt("use the tool")
3612            .add_hook(RecordingDeltaAndRetryInvalidToolHook {
3613                delta: delta_hook.clone(),
3614            })
3615            .max_turns(3)
3616            .history(Vec::<Message>::new())
3617            .max_invalid_tool_call_retries(1)
3618            .await;
3619        let mut completion_call_events = Vec::new();
3620        let mut final_response_text = None;
3621        let mut final_response_usage = Usage::new();
3622        let mut final_completion_calls = Vec::new();
3623
3624        while let Some(item) = stream.next().await {
3625            match item {
3626                Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
3627                    completion_call_events.push(completion_call);
3628                }
3629                Ok(MultiTurnStreamItem::StreamAssistantItem(
3630                    StreamedAssistantContent::ToolCallDelta { .. },
3631                )) => panic!("invalid tool-call delta should not be emitted"),
3632                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3633                    final_response_text = Some(response.output().to_string());
3634                    final_response_usage = response.usage();
3635                    final_completion_calls = response.completion_calls().to_vec();
3636                    break;
3637                }
3638                Ok(_) => {}
3639                Err(err) => panic!("unexpected streaming error: {err:?}"),
3640            }
3641        }
3642
3643        assert_eq!(final_response_text.as_deref(), Some("retried"));
3644        assert!(delta_hook.observed().is_empty());
3645        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3646        let mut first_usage = Usage::new();
3647        first_usage.total_tokens = 4;
3648        let mut second_usage = Usage::new();
3649        second_usage.total_tokens = 6;
3650        let expected_completion_calls = vec![
3651            CompletionCall::new(0, first_usage),
3652            CompletionCall::new(1, second_usage),
3653        ];
3654        assert_eq!(completion_call_events, expected_completion_calls);
3655        assert_eq!(final_completion_calls, expected_completion_calls);
3656        assert_eq!(final_response_usage.total_tokens, 10);
3657
3658        let requests = recorded.requests();
3659        assert_eq!(requests.len(), 2);
3660        let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3661        assert!(matches!(
3662            retry_history.get(1),
3663            Some(Message::Assistant { content, .. })
3664                if content.iter().any(|item| matches!(
3665                    item,
3666                    AssistantContent::Text(text) if text.text == "checking "
3667                ))
3668                    && content.iter().any(|item| matches!(
3669                        item,
3670                        AssistantContent::ToolCall(tool_call)
3671                            if tool_call.id == "tool_call_0"
3672                                && tool_call.function.name == "add"
3673                    ))
3674                    && content.iter().any(|item| matches!(
3675                    item,
3676                    AssistantContent::ToolCall(tool_call)
3677                        if tool_call.id == "tool_call_1"
3678                            && tool_call.function.name == "default_api"
3679                            && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
3680                ))
3681        ));
3682        assert!(matches!(
3683            retry_history.get(2),
3684            Some(Message::User { content })
3685                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3686                    && content.iter().any(|item| matches!(
3687                        item,
3688                        UserContent::ToolResult(result)
3689                            if result.id == "tool_call_0"
3690                                && result.call_id.as_deref() == Some("call_0")
3691                                && result.content.iter().any(|content| matches!(
3692                                    content,
3693                                    ToolResultContent::Text(text)
3694                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3695                                ))
3696                    ))
3697                    && content.iter().any(|item| matches!(
3698                    item,
3699                    UserContent::ToolResult(result)
3700                        if result.id == "tool_call_1"
3701                            && result.content.iter().any(|content| matches!(
3702                                content,
3703                                ToolResultContent::Text(text)
3704                                    if text.text == "Use the add tool instead"
3705                            ))
3706                ))
3707        ));
3708    }
3709
3710    #[tokio::test]
3711    async fn invalid_tool_call_delta_context_includes_same_turn_history_and_tool_call_id() {
3712        let invalid_hook = RecordingInvalidToolCallHook::default();
3713        let model = MockCompletionModel::from_stream_turns([
3714            vec![
3715                MockStreamEvent::text("checking "),
3716                MockStreamEvent::reasoning_delta(Some("rs_1"), "diagnostic reason"),
3717                MockStreamEvent::tool_call(
3718                    "tool_call_0",
3719                    "add",
3720                    serde_json::json!({"x": 1, "y": 2}),
3721                )
3722                .with_call_id("call_0"),
3723                MockStreamEvent::tool_call_arguments_delta(
3724                    "tool_call_1",
3725                    "internal_1",
3726                    r#"{"x":2,"y":3}"#,
3727                ),
3728                MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3729                MockStreamEvent::final_response_with_total_tokens(4),
3730            ],
3731            vec![
3732                MockStreamEvent::text("should not be requested"),
3733                MockStreamEvent::final_response_with_total_tokens(6),
3734            ],
3735        ]);
3736        let recorded = model.clone();
3737        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3738
3739        let mut stream = agent
3740            .stream_prompt("use the tool")
3741            .add_hook(invalid_hook.clone())
3742            .max_turns(3)
3743            .await;
3744        let mut error = None;
3745
3746        while let Some(item) = stream.next().await {
3747            if let Err(err) = item {
3748                error = Some(err);
3749                break;
3750            }
3751        }
3752
3753        assert!(error.is_some(), "invalid name delta should fail");
3754        assert_eq!(recorded.request_count(), 1);
3755        let contexts = invalid_hook.observed();
3756        assert_eq!(contexts.len(), 1);
3757        let context = &contexts[0];
3758        assert_eq!(context.tool_name, "default_api");
3759        assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
3760        assert_eq!(context.internal_call_id.as_deref(), Some("internal_1"));
3761        assert!(context.is_streaming);
3762        assert!(history_contains_text(&context.chat_history, "checking "));
3763        assert!(
3764            assistant_reasoning_precedes_tool_call(
3765                &context.chat_history,
3766                "diagnostic reason",
3767                "add"
3768            ),
3769            "{:?}",
3770            context.chat_history
3771        );
3772        assert!(history_contains_tool_call(&context.chat_history, "add"));
3773        assert!(history_contains_tool_call(
3774            &context.chat_history,
3775            "default_api"
3776        ));
3777    }
3778
3779    #[tokio::test]
3780    async fn invalid_tool_call_delta_retry_resets_streaming_text_delta_state() {
3781        let text_hook = RecordingTextDeltaHook::default();
3782        let model = MockCompletionModel::from_stream_turns([
3783            vec![
3784                MockStreamEvent::text("stale "),
3785                MockStreamEvent::tool_call_arguments_delta(
3786                    "tool_call_1",
3787                    "internal_1",
3788                    r#"{"x":2,"y":3}"#,
3789                ),
3790                MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3791                MockStreamEvent::final_response_with_total_tokens(4),
3792            ],
3793            vec![
3794                MockStreamEvent::text("fresh"),
3795                MockStreamEvent::final_response_with_total_tokens(6),
3796            ],
3797        ]);
3798        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3799
3800        let mut stream = agent
3801            .stream_prompt("use the tool")
3802            .add_hook(RecordingTextAndRetryInvalidToolHook {
3803                text: text_hook.clone(),
3804            })
3805            .max_turns(3)
3806            .history(Vec::<Message>::new())
3807            .max_invalid_tool_call_retries(1)
3808            .await;
3809
3810        while let Some(item) = stream.next().await {
3811            match item {
3812                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3813                Ok(_) => {}
3814                Err(err) => panic!("unexpected streaming error: {err:?}"),
3815            }
3816        }
3817
3818        assert_eq!(
3819            text_hook.observed(),
3820            vec![
3821                ("stale ".to_string(), "stale ".to_string()),
3822                ("fresh".to_string(), "fresh".to_string()),
3823            ]
3824        );
3825    }
3826
3827    #[tokio::test]
3828    async fn invalid_tool_call_delta_skip_uses_structured_tool_feedback() {
3829        let delta_hook = RecordingToolCallDeltaHook::default();
3830        let add_calls = Arc::new(AtomicU32::new(0));
3831        let model = MockCompletionModel::from_stream_turns([
3832            vec![
3833                MockStreamEvent::text("checking "),
3834                MockStreamEvent::tool_call(
3835                    "tool_call_0",
3836                    "add",
3837                    serde_json::json!({"x": 1, "y": 2}),
3838                )
3839                .with_call_id("call_0"),
3840                MockStreamEvent::tool_call_arguments_delta(
3841                    "tool_call_1",
3842                    "internal_1",
3843                    r#"{"x":2,"y":3}"#,
3844                ),
3845                MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3846                MockStreamEvent::final_response_with_total_tokens(4),
3847            ],
3848            vec![
3849                MockStreamEvent::text("continued"),
3850                MockStreamEvent::final_response_with_total_tokens(6),
3851            ],
3852        ]);
3853        let recorded = model.clone();
3854        let agent = AgentBuilder::new(model)
3855            .tool(CountingAddTool {
3856                calls: add_calls.clone(),
3857            })
3858            .build();
3859
3860        let mut stream = agent
3861            .stream_prompt("use the tool")
3862            .add_hook(RecordingDeltaAndSkipInvalidToolHook {
3863                delta: delta_hook.clone(),
3864            })
3865            .max_turns(3)
3866            .history(Vec::<Message>::new())
3867            .await;
3868        let mut skipped_tool_result = None;
3869        let mut final_response_text = None;
3870
3871        while let Some(item) = stream.next().await {
3872            match item {
3873                Ok(MultiTurnStreamItem::StreamAssistantItem(
3874                    StreamedAssistantContent::ToolCallDelta { .. },
3875                )) => panic!("invalid tool-call delta should not be emitted"),
3876                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3877                    tool_result,
3878                    internal_call_id,
3879                })) => {
3880                    assert_eq!(internal_call_id, "internal_1");
3881                    skipped_tool_result = Some(tool_result);
3882                }
3883                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3884                    final_response_text = Some(response.output().to_string());
3885                    break;
3886                }
3887                Ok(_) => {}
3888                Err(err) => panic!("unexpected streaming error: {err:?}"),
3889            }
3890        }
3891
3892        let skipped_tool_result =
3893            skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
3894        assert_eq!(skipped_tool_result.id, "tool_call_1");
3895        assert!(skipped_tool_result.call_id.is_none());
3896        assert!(skipped_tool_result.content.iter().any(|content| matches!(
3897            content,
3898            ToolResultContent::Text(text) if text.text == "default_api was skipped"
3899        )));
3900        assert_eq!(final_response_text.as_deref(), Some("continued"));
3901        assert!(delta_hook.observed().is_empty());
3902        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3903
3904        let requests = recorded.requests();
3905        assert_eq!(requests.len(), 2);
3906        let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3907        assert!(matches!(
3908            follow_up_history.get(1),
3909            Some(Message::Assistant { content, .. })
3910                if content.iter().any(|item| matches!(
3911                    item,
3912                    AssistantContent::Text(text) if text.text == "checking "
3913                ))
3914                    && content.iter().any(|item| matches!(
3915                        item,
3916                        AssistantContent::ToolCall(tool_call)
3917                            if tool_call.id == "tool_call_0"
3918                                && tool_call.function.name == "add"
3919                    ))
3920                    && content.iter().any(|item| matches!(
3921                    item,
3922                    AssistantContent::ToolCall(tool_call)
3923                        if tool_call.id == "tool_call_1"
3924                            && tool_call.function.name == "default_api"
3925                            && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
3926                ))
3927        ));
3928        assert!(matches!(
3929            follow_up_history.get(2),
3930            Some(Message::User { content })
3931                if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3932                    && content.iter().any(|item| matches!(
3933                        item,
3934                        UserContent::ToolResult(result)
3935                            if result.id == "tool_call_0"
3936                                && result.call_id.as_deref() == Some("call_0")
3937                                && result.content.iter().any(|content| matches!(
3938                                    content,
3939                                    ToolResultContent::Text(text)
3940                                        if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3941                                ))
3942                    ))
3943                    && content.iter().any(|item| matches!(
3944                    item,
3945                    UserContent::ToolResult(result)
3946                        if result.id == "tool_call_1"
3947                            && result.content.iter().any(|content| matches!(
3948                                content,
3949                                ToolResultContent::Text(text)
3950                                    if text.text == "default_api was skipped"
3951                            ))
3952                ))
3953        ));
3954    }
3955
3956    #[tokio::test]
3957    async fn streaming_retry_budget_exhaustion_history_contains_invalid_tool_call() {
3958        let model = MockCompletionModel::from_stream_turns([
3959            vec![
3960                MockStreamEvent::tool_call(
3961                    "tool_call_1",
3962                    "default_api",
3963                    serde_json::json!({"x": 1, "y": 2}),
3964                ),
3965                MockStreamEvent::final_response_with_total_tokens(4),
3966            ],
3967            vec![
3968                MockStreamEvent::text("should not be requested"),
3969                MockStreamEvent::final_response_with_total_tokens(6),
3970            ],
3971        ]);
3972        let recorded = model.clone();
3973        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3974
3975        let mut stream = agent
3976            .stream_prompt("use the tool")
3977            .add_hook(RetryDefaultApiHook)
3978            .max_turns(3)
3979            .max_invalid_tool_call_retries(0)
3980            .await;
3981        let mut error = None;
3982
3983        while let Some(item) = stream.next().await {
3984            if let Err(err) = item {
3985                error = Some(err);
3986                break;
3987            }
3988        }
3989
3990        let error = error.expect("retry budget exhaustion should fail");
3991        match error {
3992            StreamingError::Prompt(err) => match *err {
3993                PromptError::UnknownToolCall {
3994                    tool_name,
3995                    chat_history,
3996                    ..
3997                } => {
3998                    assert_eq!(tool_name, "default_api");
3999                    assert!(history_contains_tool_call(&chat_history, "default_api"));
4000                }
4001                other => panic!("expected UnknownToolCall, got {other:?}"),
4002            },
4003            other => panic!("expected prompt streaming error, got {other:?}"),
4004        }
4005        assert_eq!(recorded.request_count(), 1);
4006    }
4007
4008    #[tokio::test]
4009    async fn streaming_name_delta_retry_budget_exhaustion_history_includes_same_turn_context() {
4010        let model = MockCompletionModel::from_stream_turns([
4011            vec![
4012                MockStreamEvent::text("checking "),
4013                MockStreamEvent::tool_call(
4014                    "tool_call_0",
4015                    "add",
4016                    serde_json::json!({"x": 1, "y": 2}),
4017                )
4018                .with_call_id("call_0"),
4019                MockStreamEvent::tool_call_arguments_delta(
4020                    "tool_call_1",
4021                    "internal_1",
4022                    r#"{"x":2,"y":3}"#,
4023                ),
4024                MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
4025                MockStreamEvent::final_response_with_total_tokens(4),
4026            ],
4027            vec![
4028                MockStreamEvent::text("should not be requested"),
4029                MockStreamEvent::final_response_with_total_tokens(6),
4030            ],
4031        ]);
4032        let recorded = model.clone();
4033        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4034
4035        let mut stream = agent
4036            .stream_prompt("use the tool")
4037            .add_hook(RetryDefaultApiHook)
4038            .max_turns(3)
4039            .max_invalid_tool_call_retries(0)
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        let error = error.expect("retry budget exhaustion should fail");
4051        match error {
4052            StreamingError::Prompt(err) => match *err {
4053                PromptError::UnknownToolCall {
4054                    tool_name,
4055                    chat_history,
4056                    ..
4057                } => {
4058                    assert_eq!(tool_name, "default_api");
4059                    assert!(history_contains_text(&chat_history, "checking "));
4060                    assert!(history_contains_tool_call(&chat_history, "add"));
4061                    assert!(history_contains_tool_call(&chat_history, "default_api"));
4062                }
4063                other => panic!("expected UnknownToolCall, got {other:?}"),
4064            },
4065            other => panic!("expected prompt streaming error, got {other:?}"),
4066        }
4067        assert_eq!(recorded.request_count(), 1);
4068    }
4069
4070    #[tokio::test]
4071    async fn completed_unknown_tool_call_after_text_fails_before_finish_hook_or_later_emit() {
4072        let add_calls = Arc::new(AtomicU32::new(0));
4073        let model = MockCompletionModel::from_stream_turns([
4074            vec![
4075                MockStreamEvent::text("thinking "),
4076                MockStreamEvent::tool_call(
4077                    "tool_call_1",
4078                    "default_api",
4079                    serde_json::json!({"x": 1, "y": 2}),
4080                ),
4081                MockStreamEvent::final_response_with_total_tokens(4),
4082            ],
4083            vec![
4084                MockStreamEvent::text("should not be requested"),
4085                MockStreamEvent::final_response_with_total_tokens(6),
4086            ],
4087        ]);
4088        let recorded = model.clone();
4089        let agent = AgentBuilder::new(model)
4090            .tool(CountingAddTool {
4091                calls: add_calls.clone(),
4092            })
4093            .build();
4094
4095        let mut stream = agent
4096            .stream_prompt("use the tool")
4097            .add_hook(PanicOnUnknownToolHook)
4098            .max_turns(3)
4099            .await;
4100        let mut saw_text = false;
4101        let mut saw_completion_call = false;
4102        let mut saw_final_response = false;
4103        let mut saw_tool_call = false;
4104        let mut saw_tool_result = false;
4105        let mut error = None;
4106
4107        while let Some(item) = stream.next().await {
4108            match item {
4109                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(_))) => {
4110                    saw_text = true;
4111                }
4112                Ok(MultiTurnStreamItem::CompletionCall(_)) => {
4113                    saw_completion_call = true;
4114                }
4115                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Final(
4116                    _,
4117                )))
4118                | Ok(MultiTurnStreamItem::FinalResponse(_)) => {
4119                    saw_final_response = true;
4120                }
4121                Ok(MultiTurnStreamItem::StreamAssistantItem(
4122                    StreamedAssistantContent::ToolCall { .. },
4123                )) => {
4124                    saw_tool_call = true;
4125                }
4126                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4127                    ..
4128                })) => {
4129                    saw_tool_result = true;
4130                }
4131                Ok(_) => {}
4132                Err(err) => {
4133                    error = Some(err);
4134                    break;
4135                }
4136            }
4137        }
4138
4139        assert!(saw_text);
4140        assert!(!saw_completion_call);
4141        assert!(!saw_final_response);
4142        assert!(!saw_tool_call);
4143        assert!(!saw_tool_result);
4144        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4145        let error = error.expect("completed unknown tool call should fail immediately");
4146        match error {
4147            StreamingError::Prompt(err) => match *err {
4148                PromptError::UnknownToolCall {
4149                    tool_name,
4150                    available_tools,
4151                    allowed_tools,
4152                    chat_history,
4153                } => {
4154                    assert_eq!(tool_name, "default_api");
4155                    assert_eq!(available_tools, vec!["add".to_string()]);
4156                    assert_eq!(allowed_tools, vec!["add".to_string()]);
4157                    assert!(history_contains_tool_call(&chat_history, "default_api"));
4158                }
4159                other => panic!("expected UnknownToolCall, got {other:?}"),
4160            },
4161            other => panic!("expected prompt streaming error, got {other:?}"),
4162        }
4163        assert_eq!(recorded.request_count(), 1);
4164    }
4165
4166    #[tokio::test]
4167    async fn mixed_streaming_tool_calls_fail_before_any_tool_execution() {
4168        let add_calls = Arc::new(AtomicU32::new(0));
4169        let model = MockCompletionModel::from_stream_turns([
4170            vec![
4171                MockStreamEvent::tool_call(
4172                    "tool_call_1",
4173                    "add",
4174                    serde_json::json!({"x": 1, "y": 2}),
4175                )
4176                .with_call_id("call_1"),
4177                MockStreamEvent::tool_call(
4178                    "tool_call_2",
4179                    "default_api",
4180                    serde_json::json!({"x": 3, "y": 4}),
4181                ),
4182                MockStreamEvent::final_response_with_total_tokens(4),
4183            ],
4184            vec![
4185                MockStreamEvent::text("should not be requested"),
4186                MockStreamEvent::final_response_with_total_tokens(6),
4187            ],
4188        ]);
4189        let recorded = model.clone();
4190        let agent = AgentBuilder::new(model)
4191            .tool(CountingAddTool {
4192                calls: add_calls.clone(),
4193            })
4194            .build();
4195
4196        let mut stream = agent
4197            .stream_prompt("use tools")
4198            .add_hook(PanicOnUnknownToolHook)
4199            .max_turns(3)
4200            .await;
4201        let mut saw_completion_call = false;
4202        let mut saw_tool_call = false;
4203        let mut saw_tool_result = false;
4204        let mut error = None;
4205
4206        while let Some(item) = stream.next().await {
4207            match item {
4208                Ok(MultiTurnStreamItem::CompletionCall(_)) => {
4209                    saw_completion_call = true;
4210                }
4211                Ok(MultiTurnStreamItem::StreamAssistantItem(
4212                    StreamedAssistantContent::ToolCall { .. },
4213                )) => {
4214                    saw_tool_call = true;
4215                }
4216                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4217                    ..
4218                })) => {
4219                    saw_tool_result = true;
4220                }
4221                Ok(_) => {}
4222                Err(err) => {
4223                    error = Some(err);
4224                    break;
4225                }
4226            }
4227        }
4228
4229        assert!(!saw_completion_call);
4230        assert!(!saw_tool_call);
4231        assert!(!saw_tool_result);
4232        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4233        let error = error.expect("mixed unknown streamed tool call should fail");
4234        match error {
4235            StreamingError::Prompt(err) => match *err {
4236                PromptError::UnknownToolCall {
4237                    tool_name,
4238                    available_tools,
4239                    allowed_tools,
4240                    chat_history,
4241                } => {
4242                    assert_eq!(tool_name, "default_api");
4243                    assert_eq!(available_tools, vec!["add".to_string()]);
4244                    assert_eq!(allowed_tools, vec!["add".to_string()]);
4245                    assert!(history_contains_tool_call(&chat_history, "default_api"));
4246                }
4247                other => panic!("expected UnknownToolCall, got {other:?}"),
4248            },
4249            other => panic!("expected prompt streaming error, got {other:?}"),
4250        }
4251        assert_eq!(recorded.request_count(), 1);
4252    }
4253
4254    #[tokio::test]
4255    async fn multiple_valid_streaming_tool_calls_execute_after_batch_validation() {
4256        let add_calls = Arc::new(AtomicU32::new(0));
4257        let subtract_calls = Arc::new(AtomicU32::new(0));
4258        let model = MockCompletionModel::from_stream_turns([
4259            vec![
4260                MockStreamEvent::tool_call(
4261                    "tool_call_1",
4262                    "add",
4263                    serde_json::json!({"x": 1, "y": 2}),
4264                )
4265                .with_call_id("call_1"),
4266                MockStreamEvent::tool_call(
4267                    "tool_call_2",
4268                    "subtract",
4269                    serde_json::json!({"x": 8, "y": 3}),
4270                )
4271                .with_call_id("call_2"),
4272                MockStreamEvent::final_response_with_total_tokens(4),
4273            ],
4274            vec![
4275                MockStreamEvent::text("done"),
4276                MockStreamEvent::final_response_with_total_tokens(6),
4277            ],
4278        ]);
4279        let recorded = model.clone();
4280        let agent = AgentBuilder::new(model)
4281            .tool(CountingAddTool {
4282                calls: add_calls.clone(),
4283            })
4284            .tool(CountingSubtractTool {
4285                calls: subtract_calls.clone(),
4286            })
4287            .build();
4288
4289        let mut stream = agent.stream_prompt("use tools").max_turns(3).await;
4290        let mut tool_call_names = Vec::new();
4291        let mut tool_result_ids = Vec::new();
4292        let mut final_response_text = None;
4293
4294        while let Some(item) = stream.next().await {
4295            match item {
4296                Ok(MultiTurnStreamItem::StreamAssistantItem(
4297                    StreamedAssistantContent::ToolCall { tool_call, .. },
4298                )) => {
4299                    tool_call_names.push(tool_call.function.name);
4300                }
4301                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4302                    tool_result,
4303                    ..
4304                })) => {
4305                    tool_result_ids.push(tool_result.id);
4306                }
4307                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4308                    final_response_text = Some(response.output().to_owned());
4309                    break;
4310                }
4311                Ok(_) => {}
4312                Err(err) => panic!("unexpected streaming error: {err:?}"),
4313            }
4314        }
4315
4316        assert_eq!(
4317            tool_call_names,
4318            vec!["add".to_string(), "subtract".to_string()]
4319        );
4320        assert_eq!(
4321            tool_result_ids,
4322            vec!["tool_call_1".to_string(), "tool_call_2".to_string()]
4323        );
4324        assert_eq!(add_calls.load(Ordering::SeqCst), 1);
4325        assert_eq!(subtract_calls.load(Ordering::SeqCst), 1);
4326        assert_eq!(final_response_text.as_deref(), Some("done"));
4327        assert_eq!(recorded.request_count(), 2);
4328    }
4329
4330    #[tokio::test]
4331    async fn disallowed_specific_tool_call_fails_before_streaming_second_request() {
4332        let model = MockCompletionModel::from_stream_turns([
4333            vec![
4334                MockStreamEvent::tool_call(
4335                    "tool_call_1",
4336                    "subtract",
4337                    serde_json::json!({"x": 3, "y": 1}),
4338                ),
4339                MockStreamEvent::final_response_with_total_tokens(4),
4340            ],
4341            vec![
4342                MockStreamEvent::text("should not be requested"),
4343                MockStreamEvent::final_response_with_total_tokens(6),
4344            ],
4345        ]);
4346        let recorded = model.clone();
4347        let agent = AgentBuilder::new(model)
4348            .tool(MockAddTool)
4349            .tool(MockSubtractTool)
4350            .tool_choice(ToolChoice::Specific {
4351                function_names: vec!["add".to_string()],
4352            })
4353            .build();
4354
4355        let mut stream = agent
4356            .stream_prompt("use the allowed tool")
4357            .add_hook(PanicOnUnknownToolHook)
4358            .max_turns(3)
4359            .await;
4360        let mut saw_tool_call = false;
4361        let mut error = None;
4362
4363        while let Some(item) = stream.next().await {
4364            match item {
4365                Ok(MultiTurnStreamItem::StreamAssistantItem(
4366                    StreamedAssistantContent::ToolCall { .. },
4367                )) => {
4368                    saw_tool_call = true;
4369                }
4370                Ok(_) => {}
4371                Err(err) => {
4372                    error = Some(err);
4373                    break;
4374                }
4375            }
4376        }
4377
4378        assert!(!saw_tool_call);
4379        let error = error.expect("disallowed model-emitted tool should fail");
4380        match error {
4381            StreamingError::Prompt(err) => match *err {
4382                PromptError::UnknownToolCall {
4383                    tool_name,
4384                    available_tools,
4385                    allowed_tools,
4386                    chat_history,
4387                } => {
4388                    assert_eq!(tool_name, "subtract");
4389                    assert_eq!(
4390                        available_tools,
4391                        vec!["add".to_string(), "subtract".to_string()]
4392                    );
4393                    assert_eq!(allowed_tools, vec!["add".to_string()]);
4394                    assert!(history_contains_tool_call(&chat_history, "subtract"));
4395                }
4396                other => panic!("expected UnknownToolCall, got {other:?}"),
4397            },
4398            other => panic!("expected prompt streaming error, got {other:?}"),
4399        }
4400        assert_eq!(recorded.request_count(), 1);
4401    }
4402
4403    #[tokio::test]
4404    async fn mixed_specific_tool_calls_fail_before_any_tool_execution() {
4405        let add_calls = Arc::new(AtomicU32::new(0));
4406        let model = MockCompletionModel::from_stream_turns([
4407            vec![
4408                MockStreamEvent::tool_call(
4409                    "tool_call_1",
4410                    "add",
4411                    serde_json::json!({"x": 1, "y": 2}),
4412                ),
4413                MockStreamEvent::tool_call(
4414                    "tool_call_2",
4415                    "subtract",
4416                    serde_json::json!({"x": 3, "y": 1}),
4417                ),
4418                MockStreamEvent::final_response_with_total_tokens(4),
4419            ],
4420            vec![
4421                MockStreamEvent::text("should not be requested"),
4422                MockStreamEvent::final_response_with_total_tokens(6),
4423            ],
4424        ]);
4425        let recorded = model.clone();
4426        let agent = AgentBuilder::new(model)
4427            .tool(CountingAddTool {
4428                calls: add_calls.clone(),
4429            })
4430            .tool(MockSubtractTool)
4431            .tool_choice(ToolChoice::Specific {
4432                function_names: vec!["add".to_string()],
4433            })
4434            .build();
4435
4436        let mut stream = agent
4437            .stream_prompt("use the allowed tool")
4438            .add_hook(PanicOnUnknownToolHook)
4439            .max_turns(3)
4440            .await;
4441        let mut saw_tool_call = false;
4442        let mut saw_tool_result = false;
4443        let mut error = None;
4444
4445        while let Some(item) = stream.next().await {
4446            match item {
4447                Ok(MultiTurnStreamItem::StreamAssistantItem(
4448                    StreamedAssistantContent::ToolCall { .. },
4449                )) => {
4450                    saw_tool_call = true;
4451                }
4452                Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4453                    ..
4454                })) => {
4455                    saw_tool_result = true;
4456                }
4457                Ok(_) => {}
4458                Err(err) => {
4459                    error = Some(err);
4460                    break;
4461                }
4462            }
4463        }
4464
4465        assert!(!saw_tool_call);
4466        assert!(!saw_tool_result);
4467        assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4468        let error = error.expect("mixed disallowed streamed tool call should fail");
4469        match error {
4470            StreamingError::Prompt(err) => match *err {
4471                PromptError::UnknownToolCall {
4472                    tool_name,
4473                    available_tools,
4474                    allowed_tools,
4475                    chat_history,
4476                } => {
4477                    assert_eq!(tool_name, "subtract");
4478                    assert_eq!(
4479                        available_tools,
4480                        vec!["add".to_string(), "subtract".to_string()]
4481                    );
4482                    assert_eq!(allowed_tools, vec!["add".to_string()]);
4483                    assert!(history_contains_tool_call(&chat_history, "subtract"));
4484                }
4485                other => panic!("expected UnknownToolCall, got {other:?}"),
4486            },
4487            other => panic!("expected prompt streaming error, got {other:?}"),
4488        }
4489        assert_eq!(recorded.request_count(), 1);
4490    }
4491
4492    #[tokio::test]
4493    async fn tool_choice_none_rejects_streaming_tool_call() {
4494        let model = MockCompletionModel::from_stream_turns([
4495            vec![
4496                MockStreamEvent::tool_call(
4497                    "tool_call_1",
4498                    "add",
4499                    serde_json::json!({"x": 1, "y": 2}),
4500                ),
4501                MockStreamEvent::final_response_with_total_tokens(4),
4502            ],
4503            vec![
4504                MockStreamEvent::text("should not be requested"),
4505                MockStreamEvent::final_response_with_total_tokens(6),
4506            ],
4507        ]);
4508        let recorded = model.clone();
4509        let agent = AgentBuilder::new(model)
4510            .tool(MockAddTool)
4511            .tool_choice(ToolChoice::None)
4512            .build();
4513
4514        let mut stream = agent
4515            .stream_prompt("do not use tools")
4516            .add_hook(PanicOnUnknownToolHook)
4517            .max_turns(3)
4518            .await;
4519        let mut saw_tool_call = false;
4520        let mut error = None;
4521
4522        while let Some(item) = stream.next().await {
4523            match item {
4524                Ok(MultiTurnStreamItem::StreamAssistantItem(
4525                    StreamedAssistantContent::ToolCall { .. },
4526                )) => {
4527                    saw_tool_call = true;
4528                }
4529                Ok(_) => {}
4530                Err(err) => {
4531                    error = Some(err);
4532                    break;
4533                }
4534            }
4535        }
4536
4537        assert!(!saw_tool_call);
4538        let error = error.expect("ToolChoice::None should reject returned tool calls");
4539        match error {
4540            StreamingError::Prompt(err) => match *err {
4541                PromptError::UnknownToolCall {
4542                    tool_name,
4543                    available_tools,
4544                    allowed_tools,
4545                    chat_history,
4546                } => {
4547                    assert_eq!(tool_name, "add");
4548                    assert_eq!(available_tools, vec!["add".to_string()]);
4549                    assert!(allowed_tools.is_empty());
4550                    assert!(history_contains_tool_call(&chat_history, "add"));
4551                }
4552                other => panic!("expected UnknownToolCall, got {other:?}"),
4553            },
4554            other => panic!("expected prompt streaming error, got {other:?}"),
4555        }
4556        assert_eq!(recorded.request_count(), 1);
4557    }
4558
4559    #[tokio::test]
4560    async fn tool_choice_none_rejects_streaming_tool_call_name_delta_before_hook_or_emit() {
4561        let model = MockCompletionModel::from_stream_turns([
4562            vec![
4563                MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4564                MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4565                MockStreamEvent::final_response_with_total_tokens(4),
4566            ],
4567            vec![
4568                MockStreamEvent::text("should not be requested"),
4569                MockStreamEvent::final_response_with_total_tokens(6),
4570            ],
4571        ]);
4572        let recorded = model.clone();
4573        let agent = AgentBuilder::new(model)
4574            .tool(MockAddTool)
4575            .tool_choice(ToolChoice::None)
4576            .build();
4577
4578        let mut stream = agent
4579            .stream_prompt("do not use tools")
4580            .add_hook(PanicOnUnknownToolHook)
4581            .max_turns(3)
4582            .await;
4583        let mut saw_delta = false;
4584        let mut error = None;
4585
4586        while let Some(item) = stream.next().await {
4587            match item {
4588                Ok(MultiTurnStreamItem::StreamAssistantItem(
4589                    StreamedAssistantContent::ToolCallDelta { .. },
4590                )) => {
4591                    saw_delta = true;
4592                }
4593                Ok(_) => {}
4594                Err(err) => {
4595                    error = Some(err);
4596                    break;
4597                }
4598            }
4599        }
4600
4601        assert!(!saw_delta);
4602        let error = error.expect("ToolChoice::None should reject returned tool-call deltas");
4603        match error {
4604            StreamingError::Prompt(err) => match *err {
4605                PromptError::UnknownToolCall {
4606                    tool_name,
4607                    available_tools,
4608                    allowed_tools,
4609                    chat_history,
4610                } => {
4611                    assert_eq!(tool_name, "add");
4612                    assert_eq!(available_tools, vec!["add".to_string()]);
4613                    assert!(allowed_tools.is_empty());
4614                    assert!(history_contains_tool_call(&chat_history, "add"));
4615                }
4616                other => panic!("expected UnknownToolCall, got {other:?}"),
4617            },
4618            other => panic!("expected prompt streaming error, got {other:?}"),
4619        }
4620        assert_eq!(recorded.request_count(), 1);
4621    }
4622
4623    #[tokio::test]
4624    async fn unknown_tool_call_name_delta_fails_before_streaming_delta_hook_or_emit() {
4625        let model = MockCompletionModel::from_stream_turns([
4626            vec![
4627                MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "default_api"),
4628                MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4629                MockStreamEvent::final_response_with_total_tokens(4),
4630            ],
4631            vec![
4632                MockStreamEvent::text("should not be requested"),
4633                MockStreamEvent::final_response_with_total_tokens(6),
4634            ],
4635        ]);
4636        let recorded = model.clone();
4637        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4638
4639        let mut stream = agent
4640            .stream_prompt("stream a bad tool call")
4641            .add_hook(PanicOnUnknownToolHook)
4642            .max_turns(3)
4643            .await;
4644        let mut saw_delta = false;
4645        let mut error = None;
4646
4647        while let Some(item) = stream.next().await {
4648            match item {
4649                Ok(MultiTurnStreamItem::StreamAssistantItem(
4650                    StreamedAssistantContent::ToolCallDelta { .. },
4651                )) => {
4652                    saw_delta = true;
4653                }
4654                Ok(_) => {}
4655                Err(err) => {
4656                    error = Some(err);
4657                    break;
4658                }
4659            }
4660        }
4661
4662        assert!(!saw_delta);
4663        let error = error.expect("unknown tool-call name delta should fail");
4664        match error {
4665            StreamingError::Prompt(err) => match *err {
4666                PromptError::UnknownToolCall {
4667                    tool_name,
4668                    available_tools,
4669                    allowed_tools,
4670                    chat_history,
4671                } => {
4672                    assert_eq!(tool_name, "default_api");
4673                    assert_eq!(available_tools, vec!["add".to_string()]);
4674                    assert_eq!(allowed_tools, vec!["add".to_string()]);
4675                    assert!(history_contains_tool_call(&chat_history, "default_api"));
4676                }
4677                other => panic!("expected UnknownToolCall, got {other:?}"),
4678            },
4679            other => panic!("expected prompt streaming error, got {other:?}"),
4680        }
4681        assert_eq!(recorded.request_count(), 1);
4682    }
4683
4684    #[tokio::test]
4685    async fn tool_call_args_delta_before_unknown_name_fails_before_hook_or_emit() {
4686        let model = MockCompletionModel::from_stream_turns([
4687            vec![
4688                MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4689                MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "default_api"),
4690                MockStreamEvent::final_response_with_total_tokens(4),
4691            ],
4692            vec![
4693                MockStreamEvent::text("should not be requested"),
4694                MockStreamEvent::final_response_with_total_tokens(6),
4695            ],
4696        ]);
4697        let recorded = model.clone();
4698        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4699
4700        let mut stream = agent
4701            .stream_prompt("stream a bad tool call")
4702            .add_hook(PanicOnUnknownToolHook)
4703            .max_turns(3)
4704            .await;
4705        let mut saw_delta = false;
4706        let mut error = None;
4707
4708        while let Some(item) = stream.next().await {
4709            match item {
4710                Ok(MultiTurnStreamItem::StreamAssistantItem(
4711                    StreamedAssistantContent::ToolCallDelta { .. },
4712                )) => {
4713                    saw_delta = true;
4714                }
4715                Ok(_) => {}
4716                Err(err) => {
4717                    error = Some(err);
4718                    break;
4719                }
4720            }
4721        }
4722
4723        assert!(!saw_delta);
4724        let error = error.expect("unknown tool-call name should reject buffered args");
4725        match error {
4726            StreamingError::Prompt(err) => match *err {
4727                PromptError::UnknownToolCall {
4728                    tool_name,
4729                    available_tools,
4730                    allowed_tools,
4731                    chat_history,
4732                } => {
4733                    assert_eq!(tool_name, "default_api");
4734                    assert_eq!(available_tools, vec!["add".to_string()]);
4735                    assert_eq!(allowed_tools, vec!["add".to_string()]);
4736                    assert!(history_contains_tool_call(&chat_history, "default_api"));
4737                }
4738                other => panic!("expected UnknownToolCall, got {other:?}"),
4739            },
4740            other => panic!("expected prompt streaming error, got {other:?}"),
4741        }
4742        assert_eq!(recorded.request_count(), 1);
4743    }
4744
4745    #[tokio::test]
4746    async fn tool_call_args_delta_before_valid_name_buffers_then_emits_in_safe_order() {
4747        let model = MockCompletionModel::from_stream_turns([[
4748            MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
4749            MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4750            MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
4751            MockStreamEvent::final_response_with_total_tokens(3),
4752        ]]);
4753        let hook = RecordingToolCallDeltaHook::default();
4754        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4755
4756        let mut stream = agent
4757            .stream_prompt("stream a tool call")
4758            .add_hook(hook.clone())
4759            .await;
4760        let mut stream_deltas = Vec::new();
4761
4762        while let Some(item) = stream.next().await {
4763            match item {
4764                Ok(MultiTurnStreamItem::StreamAssistantItem(
4765                    StreamedAssistantContent::ToolCallDelta {
4766                        id,
4767                        internal_call_id,
4768                        content,
4769                    },
4770                )) => {
4771                    stream_deltas.push((id, internal_call_id, content));
4772                }
4773                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4774                Ok(_) => {}
4775                Err(err) => panic!("unexpected streaming error: {err:?}"),
4776            }
4777        }
4778
4779        assert_eq!(
4780            hook.observed(),
4781            vec![
4782                (
4783                    "tool_1".to_string(),
4784                    "internal_1".to_string(),
4785                    Some("add".to_string()),
4786                    String::new()
4787                ),
4788                (
4789                    "tool_1".to_string(),
4790                    "internal_1".to_string(),
4791                    None,
4792                    "{\"x\":".to_string()
4793                ),
4794                (
4795                    "tool_1".to_string(),
4796                    "internal_1".to_string(),
4797                    None,
4798                    "1}".to_string()
4799                ),
4800            ]
4801        );
4802        assert_eq!(
4803            stream_deltas,
4804            vec![
4805                (
4806                    "tool_1".to_string(),
4807                    "internal_1".to_string(),
4808                    ToolCallDeltaContent::Name("add".to_string())
4809                ),
4810                (
4811                    "tool_1".to_string(),
4812                    "internal_1".to_string(),
4813                    ToolCallDeltaContent::Delta("{\"x\":".to_string())
4814                ),
4815                (
4816                    "tool_1".to_string(),
4817                    "internal_1".to_string(),
4818                    ToolCallDeltaContent::Delta("1}".to_string())
4819                ),
4820            ]
4821        );
4822    }
4823
4824    #[tokio::test]
4825    async fn tool_call_args_delta_without_name_errors_at_stream_end() {
4826        let model = MockCompletionModel::from_stream_turns([
4827            vec![
4828                MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4829                MockStreamEvent::final_response_with_total_tokens(4),
4830            ],
4831            vec![
4832                MockStreamEvent::text("should not be requested"),
4833                MockStreamEvent::final_response_with_total_tokens(6),
4834            ],
4835        ]);
4836        let recorded = model.clone();
4837        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4838
4839        let mut stream = agent
4840            .stream_prompt("stream an incomplete tool call")
4841            .add_hook(PanicOnUnknownToolHook)
4842            .max_turns(3)
4843            .await;
4844        let mut saw_delta = false;
4845        let mut saw_completion_call = false;
4846        let mut saw_final_response = false;
4847        let mut error = None;
4848
4849        while let Some(item) = stream.next().await {
4850            match item {
4851                Ok(MultiTurnStreamItem::StreamAssistantItem(
4852                    StreamedAssistantContent::ToolCallDelta { .. },
4853                )) => {
4854                    saw_delta = true;
4855                }
4856                Ok(MultiTurnStreamItem::CompletionCall(_)) => {
4857                    saw_completion_call = true;
4858                }
4859                Ok(MultiTurnStreamItem::FinalResponse(_)) => {
4860                    saw_final_response = true;
4861                }
4862                Ok(_) => {}
4863                Err(err) => {
4864                    error = Some(err);
4865                    break;
4866                }
4867            }
4868        }
4869
4870        assert!(!saw_delta);
4871        assert!(!saw_completion_call);
4872        assert!(!saw_final_response);
4873        let error = error.expect("unterminated tool-call args delta should fail");
4874        match error {
4875            StreamingError::Completion(CompletionError::ResponseError(message)) => {
4876                assert!(
4877                    message.contains("streamed tool call arguments"),
4878                    "{message}"
4879                );
4880                assert!(message.contains("tool_1"), "{message}");
4881                assert!(message.contains("internal_1"), "{message}");
4882            }
4883            other => panic!("expected completion response error, got {other:?}"),
4884        }
4885        assert_eq!(recorded.request_count(), 1);
4886    }
4887
4888    #[tokio::test]
4889    async fn tool_choice_none_buffers_args_then_rejects_name_without_emit() {
4890        let model = MockCompletionModel::from_stream_turns([
4891            vec![
4892                MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4893                MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4894                MockStreamEvent::final_response_with_total_tokens(4),
4895            ],
4896            vec![
4897                MockStreamEvent::text("should not be requested"),
4898                MockStreamEvent::final_response_with_total_tokens(6),
4899            ],
4900        ]);
4901        let recorded = model.clone();
4902        let agent = AgentBuilder::new(model)
4903            .tool(MockAddTool)
4904            .tool_choice(ToolChoice::None)
4905            .build();
4906
4907        let mut stream = agent
4908            .stream_prompt("do not use tools")
4909            .add_hook(PanicOnUnknownToolHook)
4910            .max_turns(3)
4911            .await;
4912        let mut saw_delta = false;
4913        let mut error = None;
4914
4915        while let Some(item) = stream.next().await {
4916            match item {
4917                Ok(MultiTurnStreamItem::StreamAssistantItem(
4918                    StreamedAssistantContent::ToolCallDelta { .. },
4919                )) => {
4920                    saw_delta = true;
4921                }
4922                Ok(_) => {}
4923                Err(err) => {
4924                    error = Some(err);
4925                    break;
4926                }
4927            }
4928        }
4929
4930        assert!(!saw_delta);
4931        let error = error.expect("ToolChoice::None should reject buffered tool-call deltas");
4932        match error {
4933            StreamingError::Prompt(err) => match *err {
4934                PromptError::UnknownToolCall {
4935                    tool_name,
4936                    available_tools,
4937                    allowed_tools,
4938                    chat_history,
4939                } => {
4940                    assert_eq!(tool_name, "add");
4941                    assert_eq!(available_tools, vec!["add".to_string()]);
4942                    assert!(allowed_tools.is_empty());
4943                    assert!(history_contains_tool_call(&chat_history, "add"));
4944                }
4945                other => panic!("expected UnknownToolCall, got {other:?}"),
4946            },
4947            other => panic!("expected prompt streaming error, got {other:?}"),
4948        }
4949        assert_eq!(recorded.request_count(), 1);
4950    }
4951
4952    #[tokio::test]
4953    async fn stream_prompt_emits_tool_call_deltas_without_hook() {
4954        let model = MockCompletionModel::from_stream_turns([[
4955            MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4956            MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
4957            MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
4958            MockStreamEvent::final_response_with_total_tokens(3),
4959        ]]);
4960        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4961
4962        let mut stream = agent.stream_prompt("stream a tool call").await;
4963        let mut deltas = Vec::new();
4964
4965        while let Some(item) = stream.next().await {
4966            match item {
4967                Ok(MultiTurnStreamItem::StreamAssistantItem(
4968                    StreamedAssistantContent::ToolCallDelta {
4969                        id,
4970                        internal_call_id,
4971                        content,
4972                    },
4973                )) => {
4974                    deltas.push((id, internal_call_id, content));
4975                }
4976                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4977                Ok(_) => {}
4978                Err(err) => panic!("unexpected streaming error: {err:?}"),
4979            }
4980        }
4981
4982        assert_eq!(
4983            deltas,
4984            vec![
4985                (
4986                    "tool_1".to_string(),
4987                    "internal_1".to_string(),
4988                    ToolCallDeltaContent::Name("add".to_string())
4989                ),
4990                (
4991                    "tool_1".to_string(),
4992                    "internal_1".to_string(),
4993                    ToolCallDeltaContent::Delta("{\"x\":".to_string())
4994                ),
4995                (
4996                    "tool_1".to_string(),
4997                    "internal_1".to_string(),
4998                    ToolCallDeltaContent::Delta("1}".to_string())
4999                ),
5000            ]
5001        );
5002    }
5003
5004    #[tokio::test]
5005    async fn stream_prompt_emits_tool_call_deltas_after_hook_continue() {
5006        let model = MockCompletionModel::from_stream_turns([[
5007            MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
5008            MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
5009            MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
5010            MockStreamEvent::final_response_with_total_tokens(3),
5011        ]]);
5012        let hook = RecordingToolCallDeltaHook::default();
5013        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5014
5015        let mut stream = agent
5016            .stream_prompt("stream a tool call")
5017            .add_hook(hook.clone())
5018            .await;
5019        let mut stream_deltas = Vec::new();
5020
5021        while let Some(item) = stream.next().await {
5022            match item {
5023                Ok(MultiTurnStreamItem::StreamAssistantItem(
5024                    StreamedAssistantContent::ToolCallDelta {
5025                        id,
5026                        internal_call_id,
5027                        content,
5028                    },
5029                )) => {
5030                    stream_deltas.push((id, internal_call_id, content));
5031                }
5032                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5033                Ok(_) => {}
5034                Err(err) => panic!("unexpected streaming error: {err:?}"),
5035            }
5036        }
5037
5038        assert_eq!(
5039            hook.observed(),
5040            vec![
5041                (
5042                    "tool_1".to_string(),
5043                    "internal_1".to_string(),
5044                    Some("add".to_string()),
5045                    String::new()
5046                ),
5047                (
5048                    "tool_1".to_string(),
5049                    "internal_1".to_string(),
5050                    None,
5051                    "{\"x\":".to_string()
5052                ),
5053                (
5054                    "tool_1".to_string(),
5055                    "internal_1".to_string(),
5056                    None,
5057                    "1}".to_string()
5058                ),
5059            ]
5060        );
5061        assert_eq!(
5062            stream_deltas,
5063            vec![
5064                (
5065                    "tool_1".to_string(),
5066                    "internal_1".to_string(),
5067                    ToolCallDeltaContent::Name("add".to_string())
5068                ),
5069                (
5070                    "tool_1".to_string(),
5071                    "internal_1".to_string(),
5072                    ToolCallDeltaContent::Delta("{\"x\":".to_string())
5073                ),
5074                (
5075                    "tool_1".to_string(),
5076                    "internal_1".to_string(),
5077                    ToolCallDeltaContent::Delta("1}".to_string())
5078                ),
5079            ]
5080        );
5081    }
5082
5083    #[tokio::test]
5084    async fn stream_prompt_tool_call_deltas_hook_termination_prevents_delta_emit() {
5085        let model = MockCompletionModel::from_stream_turns([[
5086            MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
5087            MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
5088            MockStreamEvent::final_response_with_total_tokens(3),
5089        ]]);
5090        let hook = TerminatingToolCallDeltaHook::default();
5091        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5092
5093        let mut stream = agent
5094            .stream_prompt("stream a tool call")
5095            .add_hook(hook.clone())
5096            .await;
5097        let mut saw_delta = false;
5098        let mut saw_final_response = false;
5099        let mut error_message = None;
5100
5101        while let Some(item) = stream.next().await {
5102            match item {
5103                Ok(MultiTurnStreamItem::StreamAssistantItem(
5104                    StreamedAssistantContent::ToolCallDelta { .. },
5105                )) => {
5106                    saw_delta = true;
5107                }
5108                Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5109                    saw_final_response = true;
5110                }
5111                Ok(_) => {}
5112                Err(err) => {
5113                    error_message = Some(err.to_string());
5114                    break;
5115                }
5116            }
5117        }
5118
5119        assert_eq!(
5120            hook.observed(),
5121            vec![(
5122                "tool_1".to_string(),
5123                "internal_1".to_string(),
5124                Some("add".to_string()),
5125                String::new()
5126            )]
5127        );
5128        assert!(!saw_delta);
5129        assert!(!saw_final_response);
5130        assert!(
5131            error_message
5132                .as_deref()
5133                .is_some_and(|message| message.contains("PromptCancelled: stop on tool call delta")),
5134            "expected hook termination error, got {error_message:?}"
5135        );
5136    }
5137
5138    #[tokio::test]
5139    async fn stream_prompt_exposes_completion_calls() {
5140        let first_call_usage = usage(10, 2);
5141        let second_call_usage = usage(25, 5);
5142        let model = MockCompletionModel::from_stream_turns([
5143            vec![
5144                MockStreamEvent::tool_call(
5145                    "tool_call_1",
5146                    "add",
5147                    serde_json::json!({"x": 1, "y": 2}),
5148                )
5149                .with_call_id("call_1"),
5150                MockStreamEvent::final_response(first_call_usage),
5151            ],
5152            vec![
5153                MockStreamEvent::text("done"),
5154                MockStreamEvent::final_response(second_call_usage),
5155            ],
5156        ]);
5157        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5158        let empty_history: &[Message] = &[];
5159
5160        let mut stream = agent
5161            .stream_prompt("do tool work")
5162            .history(empty_history)
5163            .max_turns(3)
5164            .await;
5165        let mut completion_calls_events = Vec::new();
5166        let mut final_response = None;
5167
5168        while let Some(item) = stream.next().await {
5169            match item {
5170                Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
5171                    completion_calls_events.push(call_usage);
5172                }
5173                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5174                    final_response = Some(response);
5175                    break;
5176                }
5177                Ok(_) => {}
5178                Err(err) => panic!("unexpected streaming error: {err:?}"),
5179            }
5180        }
5181
5182        assert_eq!(
5183            completion_calls_events,
5184            vec![
5185                CompletionCall::new(0, first_call_usage),
5186                CompletionCall::new(1, second_call_usage)
5187            ]
5188        );
5189
5190        let final_response = final_response.expect("expected final response");
5191        assert_eq!(
5192            final_response.usage(),
5193            Usage {
5194                input_tokens: 35,
5195                output_tokens: 7,
5196                total_tokens: 42,
5197                cached_input_tokens: 0,
5198                cache_creation_input_tokens: 0,
5199                tool_use_prompt_tokens: 0,
5200                reasoning_tokens: 0,
5201            }
5202        );
5203        assert_eq!(
5204            final_response.completion_calls(),
5205            &[
5206                CompletionCall::new(0, first_call_usage),
5207                CompletionCall::new(1, second_call_usage)
5208            ]
5209        );
5210    }
5211
5212    #[tokio::test(flavor = "current_thread")]
5213    async fn stream_prompt_records_single_call_usage_on_chat_span_under_outer_span() {
5214        let call_usage = usage(10, 2);
5215        let model = MockCompletionModel::from_stream_turns([[
5216            MockStreamEvent::text("done"),
5217            MockStreamEvent::final_response(call_usage),
5218        ]]);
5219        let agent = AgentBuilder::new(model).build();
5220
5221        assert_stream_usage_recorded_on_chat_spans(agent, "say done", 1, &[call_usage]).await;
5222    }
5223
5224    #[tokio::test(flavor = "current_thread")]
5225    async fn stream_prompt_records_multi_turn_usage_on_chat_spans_under_outer_span() {
5226        let first_call_usage = usage(10, 2);
5227        let second_call_usage = usage(25, 5);
5228        let model = MockCompletionModel::from_stream_turns([
5229            vec![
5230                MockStreamEvent::tool_call(
5231                    "tool_call_1",
5232                    "add",
5233                    serde_json::json!({"x": 1, "y": 2}),
5234                )
5235                .with_call_id("call_1"),
5236                MockStreamEvent::final_response(first_call_usage),
5237            ],
5238            vec![
5239                MockStreamEvent::text("done"),
5240                MockStreamEvent::final_response(second_call_usage),
5241            ],
5242        ]);
5243        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5244
5245        assert_stream_usage_recorded_on_chat_spans(
5246            agent,
5247            "do tool work",
5248            3,
5249            &[first_call_usage, second_call_usage],
5250        )
5251        .await;
5252    }
5253
5254    #[tokio::test]
5255    async fn stream_prompt_emits_completion_call_before_finish_hook_termination() {
5256        let call_usage = usage(10, 2);
5257        let model = MockCompletionModel::from_stream_turns([[
5258            MockStreamEvent::text("done"),
5259            MockStreamEvent::final_response(call_usage),
5260        ]]);
5261        let agent = AgentBuilder::new(model).build();
5262
5263        let mut stream = agent
5264            .stream_prompt("say done")
5265            .add_hook(TerminateOnStreamFinish)
5266            .await;
5267        let mut completion_calls = Vec::new();
5268        let mut saw_error = false;
5269
5270        while let Some(item) = stream.next().await {
5271            match item {
5272                Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
5273                    completion_calls.push(completion_call);
5274                }
5275                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5276                    panic!("unexpected final response after hook termination: {response:?}");
5277                }
5278                Ok(_) => {}
5279                Err(_) => {
5280                    saw_error = true;
5281                    break;
5282                }
5283            }
5284        }
5285
5286        assert_eq!(completion_calls, vec![CompletionCall::new(0, call_usage)]);
5287        assert!(saw_error);
5288    }
5289
5290    #[tokio::test]
5291    async fn stream_prompt_completion_calls_records_unreported_usage() {
5292        let second_call_usage = usage(25, 5);
5293        let model = MockCompletionModel::from_stream_turns([
5294            vec![
5295                MockStreamEvent::tool_call(
5296                    "tool_call_1",
5297                    "add",
5298                    serde_json::json!({"x": 1, "y": 2}),
5299                )
5300                .with_call_id("call_1"),
5301            ],
5302            vec![
5303                MockStreamEvent::text("done"),
5304                MockStreamEvent::final_response(second_call_usage),
5305            ],
5306        ]);
5307        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5308        let empty_history: &[Message] = &[];
5309
5310        let mut stream = agent
5311            .stream_prompt("do tool work")
5312            .history(empty_history)
5313            .max_turns(3)
5314            .await;
5315        let mut completion_calls_events = Vec::new();
5316        let mut final_response = None;
5317
5318        while let Some(item) = stream.next().await {
5319            match item {
5320                Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
5321                    completion_calls_events.push(call_usage);
5322                }
5323                Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5324                    final_response = Some(response);
5325                    break;
5326                }
5327                Ok(_) => {}
5328                Err(err) => panic!("unexpected streaming error: {err:?}"),
5329            }
5330        }
5331
5332        let expected_usage = vec![
5333            CompletionCall::new(0, Usage::new()),
5334            CompletionCall::new(1, second_call_usage),
5335        ];
5336        assert_eq!(completion_calls_events, expected_usage);
5337
5338        let final_response = final_response.expect("expected final response");
5339        assert_eq!(final_response.completion_calls(), expected_usage.as_slice());
5340    }
5341
5342    #[tokio::test]
5343    async fn final_response_matches_streamed_text_when_provider_final_is_textless() {
5344        let agent = AgentBuilder::new(streaming_text_then_final_model()).build();
5345
5346        let mut stream = agent.stream_prompt("say hello").await;
5347        let mut streamed_text = String::new();
5348        let mut final_response_text = None;
5349
5350        while let Some(item) = stream.next().await {
5351            match item {
5352                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5353                    text,
5354                ))) => streamed_text.push_str(&text.text),
5355                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5356                    final_response_text = Some(res.output().to_owned());
5357                    break;
5358                }
5359                Ok(_) => {}
5360                Err(err) => panic!("unexpected streaming error: {err:?}"),
5361            }
5362        }
5363
5364        assert_eq!(streamed_text, "hello world");
5365        assert_eq!(final_response_text.as_deref(), Some("hello world"));
5366    }
5367
5368    #[tokio::test]
5369    async fn final_response_preserves_structured_text_metadata() {
5370        let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
5371
5372        let mut stream = agent.stream_prompt("answer with citations").await;
5373        let mut final_response = None;
5374
5375        while let Some(item) = stream.next().await {
5376            match item {
5377                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5378                    final_response = Some(res);
5379                    break;
5380                }
5381                Ok(_) => {}
5382                Err(err) => panic!("unexpected streaming error: {err:?}"),
5383            }
5384        }
5385
5386        let final_response = final_response.expect("expected final response");
5387        assert_eq!(final_response.output(), "cited answer");
5388        let metadata = text_metadata(final_response.content())
5389            .expect("expected text metadata in final content");
5390        assert_eq!(
5391            metadata["citations"][0]["encrypted_index"],
5392            "encrypted-reference"
5393        );
5394    }
5395
5396    #[tokio::test]
5397    async fn final_response_history_preserves_structured_text_metadata() {
5398        let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
5399
5400        let empty_history: &[Message] = &[];
5401        let mut stream = agent
5402            .stream_prompt("answer with citations")
5403            .history(empty_history)
5404            .await;
5405        let mut final_response = None;
5406
5407        while let Some(item) = stream.next().await {
5408            match item {
5409                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5410                    final_response = Some(res);
5411                    break;
5412                }
5413                Ok(_) => {}
5414                Err(err) => panic!("unexpected streaming error: {err:?}"),
5415            }
5416        }
5417
5418        let final_response = final_response.expect("expected final response");
5419        let history = final_response
5420            .messages()
5421            .expect("with_history should include final history");
5422        let assistant_content = history
5423            .iter()
5424            .find_map(|message| match message {
5425                Message::Assistant { content, .. } => Some(content),
5426                _ => None,
5427            })
5428            .expect("expected assistant message in history");
5429        let metadata =
5430            text_metadata(assistant_content).expect("expected text metadata in assistant history");
5431        assert_eq!(
5432            metadata["citations"][0]["encrypted_index"],
5433            "encrypted-reference"
5434        );
5435    }
5436
5437    #[tokio::test]
5438    async fn tool_follow_up_history_preserves_structured_text_metadata() {
5439        let model = streaming_cited_text_then_tool_model();
5440        let recorded = model.clone();
5441        let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5442        let empty_history: &[Message] = &[];
5443
5444        let mut stream = agent
5445            .stream_prompt("use a tool with citations")
5446            .history(empty_history)
5447            .max_turns(3)
5448            .await;
5449
5450        while let Some(item) = stream.next().await {
5451            match item {
5452                Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5453                Ok(_) => {}
5454                Err(err) => panic!("unexpected streaming error: {err:?}"),
5455            }
5456        }
5457
5458        let requests = recorded.requests();
5459        assert_eq!(requests.len(), 2);
5460        let follow_up_history = requests[1].chat_history.iter().collect::<Vec<_>>();
5461        let assistant_content = follow_up_history
5462            .iter()
5463            .find_map(|message| match message {
5464                Message::Assistant { content, .. } => Some(content),
5465                _ => None,
5466            })
5467            .expect("expected assistant message in follow-up history");
5468        let metadata = text_metadata(assistant_content)
5469            .expect("expected citation metadata in follow-up assistant history");
5470        assert_eq!(
5471            metadata["citations"][0]["encrypted_index"],
5472            "encrypted-reference"
5473        );
5474    }
5475
5476    #[tokio::test]
5477    async fn final_response_can_remain_empty_for_truly_textless_turns() {
5478        let agent = AgentBuilder::new(streaming_final_only_model()).build();
5479
5480        let mut stream = agent.stream_prompt("say nothing").await;
5481        let mut streamed_text = String::new();
5482        let mut final_response_text = None;
5483
5484        while let Some(item) = stream.next().await {
5485            match item {
5486                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5487                    text,
5488                ))) => streamed_text.push_str(&text.text),
5489                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5490                    final_response_text = Some(res.output().to_owned());
5491                    break;
5492                }
5493                Ok(_) => {}
5494                Err(err) => panic!("unexpected streaming error: {err:?}"),
5495            }
5496        }
5497
5498        assert!(streamed_text.is_empty());
5499        assert_eq!(final_response_text.as_deref(), Some(""));
5500    }
5501
5502    /// Background task that logs periodically to detect span leakage.
5503    /// If span leakage occurs, these logs will be prefixed with `invoke_agent{...}`.
5504    async fn background_logger(stop: Arc<AtomicBool>, leak_count: Arc<AtomicU32>) {
5505        let mut interval = tokio::time::interval(Duration::from_millis(50));
5506        let mut count = 0u32;
5507
5508        while !stop.load(Ordering::Relaxed) {
5509            interval.tick().await;
5510            count += 1;
5511
5512            tracing::event!(
5513                target: "background_logger",
5514                tracing::Level::INFO,
5515                count = count,
5516                "Background tick"
5517            );
5518
5519            // Check if we're inside an unexpected span
5520            let current = tracing::Span::current();
5521            if !current.is_disabled() && !current.is_none() {
5522                leak_count.fetch_add(1, Ordering::Relaxed);
5523            }
5524        }
5525
5526        tracing::info!(target: "background_logger", total_ticks = count, "Background logger stopped");
5527    }
5528
5529    /// Test that span context doesn't leak to concurrent tasks during streaming.
5530    ///
5531    /// This test verifies that using `.instrument()` instead of `span.enter()` in
5532    /// async_stream prevents thread-local span context from leaking to other tasks.
5533    ///
5534    /// Uses single-threaded runtime to force all tasks onto the same thread,
5535    /// making the span leak deterministic (it only occurs when tasks share a thread).
5536    #[tokio::test(flavor = "current_thread")]
5537    #[ignore = "This requires an API key"]
5538    async fn test_span_context_isolation() -> anyhow::Result<()> {
5539        let stop = Arc::new(AtomicBool::new(false));
5540        let leak_count = Arc::new(AtomicU32::new(0));
5541
5542        // Start background logger
5543        let bg_stop = stop.clone();
5544        let bg_leak = leak_count.clone();
5545        let bg_handle = tokio::spawn(async move {
5546            background_logger(bg_stop, bg_leak).await;
5547        });
5548
5549        // Small delay to let background logger start
5550        tokio::time::sleep(Duration::from_millis(100)).await;
5551
5552        // Make streaming request WITHOUT an outer span so rig creates its own invoke_agent span
5553        // (rig reuses current span if one exists, so we need to ensure there's no current span)
5554        let client = anthropic::Client::from_env()?;
5555        let agent = client
5556            .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
5557            .preamble("You are a helpful assistant.")
5558            .temperature(0.1)
5559            .max_tokens(100)
5560            .build();
5561
5562        let mut stream = agent
5563            .stream_prompt("Say 'hello world' and nothing else.")
5564            .await;
5565
5566        let mut full_content = String::new();
5567        while let Some(item) = stream.next().await {
5568            match item {
5569                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5570                    text,
5571                ))) => {
5572                    full_content.push_str(&text.text);
5573                }
5574                Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5575                    break;
5576                }
5577                Err(e) => {
5578                    tracing::warn!("Error: {:?}", e);
5579                    break;
5580                }
5581                _ => {}
5582            }
5583        }
5584
5585        tracing::info!("Got response: {:?}", full_content);
5586
5587        // Stop background logger
5588        stop.store(true, Ordering::Relaxed);
5589        bg_handle.await?;
5590
5591        let leaks = leak_count.load(Ordering::Relaxed);
5592        anyhow::ensure!(
5593            leaks == 0,
5594            "SPAN LEAK DETECTED: Background logger was inside unexpected spans {leaks} times. \
5595             This indicates that span.enter() is being used inside async_stream instead of .instrument()"
5596        );
5597
5598        Ok(())
5599    }
5600
5601    /// Test that FinalResponse contains the updated chat history when a starting
5602    /// history is provided via `.history(..)`.
5603    ///
5604    /// This verifies that:
5605    /// 1. PromptResponse.messages() returns Some when a starting history was provided
5606    /// 2. The history contains both the user prompt and assistant response
5607    #[tokio::test]
5608    #[ignore = "This requires an API key"]
5609    async fn test_chat_history_in_final_response() -> anyhow::Result<()> {
5610        use crate::message::Message;
5611
5612        let client = anthropic::Client::from_env()?;
5613        let agent = client
5614            .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
5615            .preamble("You are a helpful assistant. Keep responses brief.")
5616            .temperature(0.1)
5617            .max_tokens(50)
5618            .build();
5619
5620        // Send streaming request with history
5621        let empty_history: &[Message] = &[];
5622        let mut stream = agent
5623            .stream_prompt("Say 'hello' and nothing else.")
5624            .history(empty_history)
5625            .await;
5626
5627        // Consume the stream and collect FinalResponse
5628        let mut response_text = String::new();
5629        let mut final_history = None;
5630        while let Some(item) = stream.next().await {
5631            match item {
5632                Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5633                    text,
5634                ))) => {
5635                    response_text.push_str(&text.text);
5636                }
5637                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5638                    final_history = res.messages().map(|h| h.to_vec());
5639                    break;
5640                }
5641                Err(e) => {
5642                    return Err(e.into());
5643                }
5644                _ => {}
5645            }
5646        }
5647
5648        let history = final_history
5649            .ok_or_else(|| anyhow::anyhow!("final response should include history"))?;
5650
5651        // Should contain at least the user message
5652        anyhow::ensure!(
5653            history.iter().any(|m| matches!(m, Message::User { .. })),
5654            "History should contain the user message"
5655        );
5656
5657        // Should contain the assistant response
5658        anyhow::ensure!(
5659            history
5660                .iter()
5661                .any(|m| matches!(m, Message::Assistant { .. })),
5662            "History should contain the assistant response"
5663        );
5664
5665        tracing::info!(
5666            "History after streaming: {} messages, response: {:?}",
5667            history.len(),
5668            response_text
5669        );
5670
5671        Ok(())
5672    }
5673
5674    #[tokio::test]
5675    async fn streaming_appends_to_memory_after_final_response() {
5676        use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5677
5678        let memory = InMemoryConversationMemory::new();
5679        let agent = AgentBuilder::new(streaming_text_then_final_model())
5680            .memory(memory.clone())
5681            .build();
5682
5683        let mut stream = agent
5684            .stream_prompt("hi there")
5685            .conversation("stream-thread")
5686            .await;
5687
5688        let mut history_in_final = None;
5689        while let Some(item) = stream.next().await {
5690            match item {
5691                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5692                    history_in_final = res.messages().map(|h| h.to_vec());
5693                    break;
5694                }
5695                Ok(_) => {}
5696                Err(err) => panic!("unexpected streaming error: {err:?}"),
5697            }
5698        }
5699
5700        let final_history = history_in_final
5701            .expect("PromptResponse.messages should be populated when memory is configured");
5702        assert_eq!(
5703            final_history.len(),
5704            2,
5705            "user prompt + assistant response in final history: {final_history:?}"
5706        );
5707
5708        let stored = memory.load("stream-thread").await.unwrap();
5709        assert_eq!(stored.len(), 2, "memory should contain user + assistant");
5710    }
5711
5712    #[tokio::test]
5713    async fn streaming_reasoning_without_tools_does_not_duplicate_final_history() {
5714        let agent = AgentBuilder::new(MockCompletionModel::from_stream_turns([[
5715            MockStreamEvent::text("final answer"),
5716            MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
5717            MockStreamEvent::final_response_with_total_tokens(3),
5718        ]]))
5719        .build();
5720
5721        let mut stream = agent
5722            .stream_prompt("think before answering")
5723            .history(Vec::<Message>::new())
5724            .await;
5725
5726        let mut history_in_final = None;
5727        while let Some(item) = stream.next().await {
5728            match item {
5729                Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5730                    history_in_final = res.messages().map(|h| h.to_vec());
5731                    break;
5732                }
5733                Ok(_) => {}
5734                Err(err) => panic!("unexpected streaming error: {err:?}"),
5735            }
5736        }
5737
5738        let final_history = history_in_final
5739            .expect("PromptResponse.messages should be populated when with_history is used");
5740        assert_eq!(
5741            final_history.len(),
5742            2,
5743            "user prompt + one assistant response in final history: {final_history:?}"
5744        );
5745
5746        assert!(matches!(
5747            final_history.first(),
5748            Some(Message::User { content })
5749                if matches!(
5750                    content.first(),
5751                    UserContent::Text(text) if text.text == "think before answering"
5752                )
5753        ));
5754
5755        let assistant_messages = final_history
5756            .iter()
5757            .filter_map(|message| match message {
5758                Message::Assistant { content, .. } => Some(content),
5759                _ => None,
5760            })
5761            .collect::<Vec<_>>();
5762        assert_eq!(
5763            assistant_messages.len(),
5764            1,
5765            "reasoning turn should produce exactly one assistant history message: {final_history:?}"
5766        );
5767        let assistant_content = assistant_messages
5768            .first()
5769            .expect("expected assistant history message");
5770        assert!(assistant_content.iter().any(|item| matches!(
5771            item,
5772            AssistantContent::Text(text) if text.text == "final answer"
5773        )));
5774        assert!(assistant_content.iter().any(|item| matches!(
5775            item,
5776            AssistantContent::Reasoning(reasoning)
5777                if reasoning.id.as_deref() == Some("rs_1")
5778                    && reasoning.content.iter().any(|content| matches!(
5779                        content,
5780                        ReasoningContent::Text { text, .. } if text == "reasoned step"
5781                    ))
5782        )));
5783        let reasoning_index = assistant_content
5784            .iter()
5785            .position(|item| matches!(item, AssistantContent::Reasoning(_)))
5786            .expect("assistant history should contain reasoning");
5787        let text_index = assistant_content
5788            .iter()
5789            .position(|item| matches!(item, AssistantContent::Text(_)))
5790            .expect("assistant history should contain text");
5791        assert!(
5792            reasoning_index < text_index,
5793            "assistant reasoning must be stored before assistant text: {assistant_content:?}"
5794        );
5795    }
5796
5797    #[tokio::test]
5798    async fn streaming_with_history_overrides_memory() {
5799        use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5800
5801        let memory = InMemoryConversationMemory::new();
5802        memory
5803            .append("t1", vec![Message::user("from-memory")])
5804            .await
5805            .unwrap();
5806
5807        let agent = AgentBuilder::new(streaming_text_then_final_model())
5808            .memory(memory.clone())
5809            .build();
5810
5811        let mut stream = agent
5812            .stream_prompt("hi")
5813            .conversation("t1")
5814            .history(vec![Message::user("from-caller")])
5815            .await;
5816
5817        while let Some(item) = stream.next().await {
5818            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5819                break;
5820            }
5821        }
5822
5823        let stored = memory.load("t1").await.unwrap();
5824        assert_eq!(
5825            stored.len(),
5826            1,
5827            "with_history bypasses memory; only the pre-seeded entry remains: {stored:?}"
5828        );
5829    }
5830
5831    #[tokio::test]
5832    async fn streaming_without_memory_disables_for_request() {
5833        use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5834
5835        let memory = InMemoryConversationMemory::new();
5836        let agent = AgentBuilder::new(streaming_text_then_final_model())
5837            .memory(memory.clone())
5838            .conversation("default")
5839            .build();
5840
5841        let mut stream = agent.stream_prompt("hi").without_memory().await;
5842
5843        while let Some(item) = stream.next().await {
5844            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5845                break;
5846            }
5847        }
5848
5849        let stored = memory.load("default").await.unwrap();
5850        assert!(stored.is_empty(), "without_memory disables save");
5851    }
5852
5853    #[tokio::test]
5854    async fn streaming_load_error_yields_memory_error() {
5855        let agent = AgentBuilder::new(streaming_text_then_final_model())
5856            .memory(FailingMemory::default())
5857            .build();
5858
5859        let mut stream = agent.stream_prompt("hi").conversation("t1").await;
5860
5861        let first = stream.next().await.expect("at least one item");
5862        match first {
5863            Err(err) => {
5864                let msg = format!("{err:?}");
5865                assert!(
5866                    msg.contains("Memory") || msg.contains("memory") || msg.contains("load boom"),
5867                    "expected memory error, got: {msg}"
5868                );
5869            }
5870            Ok(other) => panic!("expected memory error, got {other:?}"),
5871        }
5872    }
5873
5874    #[tokio::test]
5875    async fn streaming_with_filter_shapes_loaded_history() {
5876        use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5877
5878        let memory = InMemoryConversationMemory::new()
5879            .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
5880        memory
5881            .append(
5882                "t1",
5883                vec![
5884                    Message::user("1"),
5885                    Message::assistant("2"),
5886                    Message::user("3"),
5887                    Message::assistant("4"),
5888                ],
5889            )
5890            .await
5891            .unwrap();
5892
5893        let model = MockCompletionModel::from_stream_turns([[
5894            MockStreamEvent::text("ok"),
5895            MockStreamEvent::final_response_with_total_tokens(1),
5896        ]]);
5897        let recorded = model.clone();
5898        let agent = AgentBuilder::new(model).memory(memory).build();
5899
5900        let mut stream = agent.stream_prompt("ping").conversation("t1").await;
5901        while let Some(item) = stream.next().await {
5902            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5903                break;
5904            }
5905        }
5906
5907        let received = recorded.requests()[0]
5908            .chat_history
5909            .iter()
5910            .cloned()
5911            .collect::<Vec<_>>();
5912        assert_eq!(
5913            received.len(),
5914            3,
5915            "window-truncated history (2) + current prompt: {received:?}"
5916        );
5917    }
5918
5919    #[tokio::test]
5920    async fn streaming_append_error_does_not_suppress_final_response() {
5921        let agent = AgentBuilder::new(streaming_text_then_final_model())
5922            .memory(AppendFailingMemory::default())
5923            .build();
5924
5925        let mut stream = agent.stream_prompt("hi").conversation("t1").await;
5926
5927        let mut saw_final = false;
5928        while let Some(item) = stream.next().await {
5929            if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5930                saw_final = true;
5931                break;
5932            }
5933        }
5934        assert!(
5935            saw_final,
5936            "FinalResponse must be yielded even when memory.append fails"
5937        );
5938    }
5939}