Skip to main content

rig_core/agent/
runner.rs

1//! [`AgentRunner`]: the hook-aware driver that turns a sans-IO
2//! [`AgentRun`] into a complete agent loop.
3//!
4//! [`AgentRun`] decides *what* to do next; it
5//! performs no IO and carries no hooks. `AgentRunner` pairs that machine with
6//! the side-effecting concerns — building and sending completion requests,
7//! executing tools, loading/saving conversation memory — and fires an
8//! [`AgentHook`] at every observable point. Both the blocking
9//! [`PromptRequest`](crate::agent::prompt_request::PromptRequest) and the
10//! [`StreamingPromptRequest`](crate::agent::prompt_request::streaming::StreamingPromptRequest)
11//! APIs are thin wrappers over an `AgentRunner`, and you can build one directly
12//! to drive an agent with custom, composable hooks:
13//!
14//! ```rust,no_run
15//! # use rig_core::agent::Agent;
16//! # use rig_core::completion::CompletionModel;
17//! # async fn example<M: CompletionModel + 'static>(agent: Agent<M>) -> Result<(), Box<dyn std::error::Error>> {
18//! let response = agent
19//!     .runner("What is 2 + 2?")
20//!     .max_turns(3)
21//!     .run()
22//!     .await?;
23//! println!("{}", response.output);
24//! # Ok(())
25//! # }
26//! ```
27
28use std::sync::{
29    Arc,
30    atomic::{AtomicU64, Ordering},
31};
32
33use futures::StreamExt;
34use tracing::{Instrument, info_span, span::Id};
35
36use super::{
37    completion::{Agent, DynamicContextStore, PreparedCompletionRequest},
38    hook::{
39        AgentHook, Flow, HookContext, HookStack, InvalidToolCallHookAction, RequestPatch, StepEvent,
40    },
41    prompt_request::{
42        PromptResponse,
43        streaming::{
44            DriveItem, DriveStream, MultiTurnStreamItem, StreamingError, TurnSource, drive_agent,
45            drive_tool_calls, record_usage_on_span, streaming_error_into_prompt,
46        },
47        tool_result_message, tool_result_output,
48    },
49    run::{
50        AgentRun, DEFAULT_OUTPUT_RETRIES, ModelTurn, ModelTurnOutcome, OutputMode, PendingToolCall,
51    },
52};
53use crate::{
54    completion::{CompletionError, CompletionModel, Document, Message, PromptError},
55    json_utils,
56    memory::ConversationMemory,
57    message::{ToolCall, ToolChoice, UserContent},
58    tool::{ToolCallExtensions, ToolExecutionResult, ToolOutcome, server::ToolServerHandle},
59};
60
61use super::UNKNOWN_AGENT_NAME;
62
63/// Build the per-turn `chat` span shared by both turn sources.
64///
65/// The span *name* must be a string literal — `tracing` bakes it into static
66/// metadata — so this is a macro parameterized by the name rather than a
67/// function (the two surfaces keep distinct names, `chat` vs `chat_streaming`,
68/// which dashboards split on). Every other field is identical across the
69/// blocking and streaming surfaces, so it lives here once instead of being
70/// copy-pasted into each `TurnSource::open_chat_span`.
71macro_rules! build_chat_span {
72    ($runner:expr, $effective_preamble:expr, $name:literal) => {
73        ::tracing::info_span!(
74            target: "rig::agent_chat",
75            parent: ::tracing::Span::current(),
76            $name,
77            gen_ai.operation.name = "chat",
78            gen_ai.agent.name = $runner.agent_name_or_default(),
79            gen_ai.system_instructions = $effective_preamble,
80            gen_ai.provider.name = ::tracing::field::Empty,
81            gen_ai.request.model = ::tracing::field::Empty,
82            gen_ai.response.id = ::tracing::field::Empty,
83            gen_ai.response.model = ::tracing::field::Empty,
84            gen_ai.usage.output_tokens = ::tracing::field::Empty,
85            gen_ai.usage.input_tokens = ::tracing::field::Empty,
86            gen_ai.usage.cache_read.input_tokens = ::tracing::field::Empty,
87            gen_ai.usage.cache_creation.input_tokens = ::tracing::field::Empty,
88            gen_ai.usage.tool_use_prompt_tokens = ::tracing::field::Empty,
89            gen_ai.usage.reasoning_tokens = ::tracing::field::Empty,
90            gen_ai.input.messages = ::tracing::field::Empty,
91            gen_ai.output.messages = ::tracing::field::Empty,
92        )
93    };
94}
95pub(crate) use build_chat_span;
96
97/// Human-readable name of a [`Flow`] variant, for fail-closed diagnostics.
98fn flow_name(flow: &Flow) -> &'static str {
99    match flow {
100        Flow::Continue => "Continue",
101        Flow::Terminate { .. } => "Terminate",
102        Flow::Skip { .. } => "Skip",
103        Flow::RewriteArgs { .. } => "RewriteArgs",
104        Flow::RewriteResult { .. } => "RewriteResult",
105        Flow::PatchRequest { .. } => "PatchRequest",
106        Flow::Fail => "Fail",
107        Flow::Retry { .. } => "Retry",
108        Flow::Repair { .. } => "Repair",
109    }
110}
111
112/// Resolve a hook's [`Flow`] for an *observe-only* event — one that honors only
113/// [`Flow::Continue`] and [`Flow::Terminate`].
114///
115/// Returns `Some(reason)` when the run must terminate, `None` to proceed. This is
116/// **fail-closed and total**: any action other than `Continue`/`Terminate` is a
117/// hook misuse and terminates the run with a diagnostic rather than being
118/// silently dropped.
119pub(crate) fn observe_flow(flow: Flow) -> Option<String> {
120    match flow {
121        Flow::Continue => None,
122        Flow::Terminate { reason } => Some(reason),
123        other => Some(format!(
124            "hook returned `{}` for an observe-only event, which only honors \
125             Continue/Terminate — terminating the run (fail-closed)",
126            flow_name(&other)
127        )),
128    }
129}
130
131/// Decision for a [`StepEvent::ToolCall`] event.
132pub(crate) enum ToolCallDecision {
133    /// Execute the tool as normal.
134    Proceed,
135    /// Execute the tool with these rewritten arguments instead of the ones the
136    /// model emitted.
137    ProceedWith(serde_json::Value),
138    /// Skip execution and return `reason` to the model as the tool result.
139    Skip(String),
140    /// Terminate the run.
141    Terminate(String),
142}
143
144/// Resolve a hook's [`Flow`] for a [`StepEvent::ToolCall`] event (honors
145/// `Continue`/`RewriteArgs`/`Skip`/`Terminate`). **Fail-closed**: any other
146/// action (e.g. `Fail`/`Retry`/`Repair`) never executes the tool — it
147/// terminates the run.
148pub(crate) fn flow_into_tool_call(flow: Flow) -> ToolCallDecision {
149    match flow {
150        Flow::Continue => ToolCallDecision::Proceed,
151        Flow::RewriteArgs { args } => ToolCallDecision::ProceedWith(args),
152        Flow::Skip { reason } => ToolCallDecision::Skip(reason),
153        Flow::Terminate { reason } => ToolCallDecision::Terminate(reason),
154        other => ToolCallDecision::Terminate(format!(
155            "hook returned `{}` for a tool-call event, which only honors \
156             Continue/RewriteArgs/Skip/Terminate — terminating the run (fail-closed) \
157             rather than executing the tool",
158            flow_name(&other)
159        )),
160    }
161}
162
163/// Decision for a [`StepEvent::ToolResult`] event.
164pub(crate) enum ToolResultDecision {
165    /// Deliver the tool's actual output to the model unchanged.
166    Keep,
167    /// Deliver this string to the model in place of the tool's actual output.
168    Replace(String),
169    /// Terminate the run.
170    Terminate(String),
171}
172
173/// Resolve a hook's [`Flow`] for a [`StepEvent::ToolResult`] event (honors
174/// `Continue`/`RewriteResult`/`Terminate`). **Fail-closed**: any other action
175/// terminates the run rather than silently delivering the tool's output.
176pub(crate) fn flow_into_tool_result(flow: Flow) -> ToolResultDecision {
177    match flow {
178        Flow::Continue => ToolResultDecision::Keep,
179        Flow::RewriteResult { result } => ToolResultDecision::Replace(result),
180        Flow::Terminate { reason } => ToolResultDecision::Terminate(reason),
181        other => ToolResultDecision::Terminate(format!(
182            "hook returned `{}` for a tool-result event, which only honors \
183             Continue/RewriteResult/Terminate — terminating the run (fail-closed)",
184            flow_name(&other)
185        )),
186    }
187}
188
189/// Decision for a [`StepEvent::CompletionCall`] event.
190pub(crate) enum CompletionCallDecision {
191    /// Build and send the request as configured.
192    Proceed,
193    /// Build and send the request with this per-turn patch applied (the merged
194    /// patch from every hook that contributed one).
195    Patch(RequestPatch),
196    /// Terminate the run.
197    Terminate(String),
198}
199
200/// Resolve a hook's [`Flow`] for a [`StepEvent::CompletionCall`] event (honors
201/// `Continue`/`PatchRequest`/`Terminate`). **Fail-closed**: any other action
202/// terminates the run rather than silently sending the request. Across a
203/// [`HookStack`] the `flow` is already the merged patch of every hook.
204pub(crate) fn flow_into_completion_call(flow: Flow) -> CompletionCallDecision {
205    match flow {
206        Flow::Continue => CompletionCallDecision::Proceed,
207        Flow::PatchRequest { patch } => CompletionCallDecision::Patch(patch),
208        Flow::Terminate { reason } => CompletionCallDecision::Terminate(reason),
209        other => CompletionCallDecision::Terminate(format!(
210            "hook returned `{}` for a completion-call event, which only honors \
211             Continue/PatchRequest/Terminate — terminating the run (fail-closed)",
212            flow_name(&other)
213        )),
214    }
215}
216
217/// Decision for a [`StepEvent::InvalidToolCall`] event.
218pub(crate) enum InvalidDecision {
219    /// Terminate the run.
220    Terminate(String),
221    /// Recover via the given [`AgentRun`] action.
222    Action(InvalidToolCallHookAction),
223}
224
225/// Resolve a hook's [`Flow`] for a [`StepEvent::InvalidToolCall`] event. All
226/// variants are meaningful here; `Continue` preserves the documented fail-fast
227/// default.
228pub(crate) fn flow_into_invalid(flow: Flow) -> InvalidDecision {
229    match flow {
230        Flow::Terminate { reason } => InvalidDecision::Terminate(reason),
231        Flow::Retry { feedback } => {
232            InvalidDecision::Action(InvalidToolCallHookAction::retry(feedback))
233        }
234        Flow::Repair { tool_name } => {
235            InvalidDecision::Action(InvalidToolCallHookAction::repair(tool_name))
236        }
237        Flow::Skip { reason } => InvalidDecision::Action(InvalidToolCallHookAction::skip(reason)),
238        // Continue and Fail both preserve fail-fast for invalid calls.
239        Flow::Continue | Flow::Fail => InvalidDecision::Action(InvalidToolCallHookAction::fail()),
240        // `RewriteArgs`/`RewriteResult`/`PatchRequest` steer a *valid* call;
241        // they cannot repair an unknown or disallowed one (use `Repair` to
242        // rewrite the name), so they are fail-closed here.
243        other @ (Flow::RewriteArgs { .. }
244        | Flow::RewriteResult { .. }
245        | Flow::PatchRequest { .. }) => InvalidDecision::Terminate(format!(
246            "hook returned `{}` for an invalid tool-call event, which only \
247                 honors Fail/Retry/Repair/Skip/Terminate — terminating the run \
248                 (fail-closed)",
249            flow_name(&other)
250        )),
251    }
252}
253
254/// A hook-aware driver over [`AgentRun`].
255///
256/// Construct one from an [`Agent`] with [`Agent::runner`], attach hooks with
257/// [`add_hook`](Self::add_hook), then call
258/// [`run`](Self::run) (blocking) or
259/// [`stream`](crate::agent::prompt_request::streaming::StreamingPromptRequest)
260/// (incremental). Hooks are held in a [`HookStack`], an ordered,
261/// runtime-composable list; `run()` and `stream()` share the same loop and fire
262/// the same events, so they behave identically apart from the streamed delta
263/// events the medium adds.
264#[non_exhaustive]
265pub struct AgentRunner<M>
266where
267    M: CompletionModel,
268{
269    pub(crate) prompt: Message,
270    pub(crate) chat_history: Option<Vec<Message>>,
271    pub(crate) max_turns: usize,
272    pub(crate) max_invalid_tool_call_retries: usize,
273    pub(crate) model: Arc<M>,
274    pub(crate) agent_name: Option<String>,
275    pub(crate) preamble: Option<String>,
276    pub(crate) static_context: Vec<Document>,
277    pub(crate) temperature: Option<f64>,
278    pub(crate) max_tokens: Option<u64>,
279    pub(crate) additional_params: Option<serde_json::Value>,
280    pub(crate) tool_server_handle: ToolServerHandle,
281    /// Per-call runtime extensions made available to every tool executed during
282    /// this run via [`Tool::call_with_extensions`](crate::tool::Tool::call_with_extensions).
283    /// Empty by default; set with the [`tool_extensions`](Self::tool_extensions())
284    /// builder.
285    pub(crate) tool_extensions: ToolCallExtensions,
286    pub(crate) dynamic_context: DynamicContextStore,
287    pub(crate) tool_choice: Option<ToolChoice>,
288    pub(crate) output_schema: Option<schemars::Schema>,
289    pub(crate) output_mode: OutputMode,
290    pub(crate) concurrency: usize,
291    pub(crate) memory: Option<Arc<dyn ConversationMemory>>,
292    pub(crate) conversation_id: Option<String>,
293    pub(crate) hooks: HookStack<M>,
294}
295
296impl<M> AgentRunner<M>
297where
298    M: CompletionModel,
299{
300    /// Build a runner from an agent, seeding it with the agent's default hook
301    /// stack. Prefer [`Agent::runner`].
302    pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
303        Self {
304            prompt: prompt.into(),
305            chat_history: None,
306            max_turns: agent.default_max_turns.unwrap_or(1),
307            max_invalid_tool_call_retries: 0,
308            model: agent.model.clone(),
309            agent_name: agent.name.clone(),
310            preamble: agent.preamble.clone(),
311            static_context: agent.static_context.clone(),
312            temperature: agent.temperature,
313            max_tokens: agent.max_tokens,
314            additional_params: agent.additional_params.clone(),
315            tool_server_handle: agent.tool_server_handle.clone(),
316            tool_extensions: ToolCallExtensions::new(),
317            dynamic_context: agent.dynamic_context.clone(),
318            tool_choice: agent.tool_choice.clone(),
319            output_schema: agent.output_schema.clone(),
320            output_mode: agent.output_mode.clone(),
321            concurrency: 1,
322            memory: agent.memory.clone(),
323            conversation_id: agent.default_conversation_id.clone(),
324            hooks: agent.hooks.clone(),
325        }
326    }
327
328    /// Append a hook to the stack (on top of any the agent already carries).
329    /// Hooks run in registration order; how their results compose is
330    /// event-dependent (`CompletionCall` request patches accumulate and merge,
331    /// `ToolCall`/`ToolResult` rewrites chain, and only observe-only/recovery
332    /// events use first-non-[`Flow::Continue`]-wins). See the
333    /// [`hook`](crate::agent::hook) module docs.
334    pub fn add_hook<H>(mut self, hook: H) -> Self
335    where
336        H: AgentHook<M> + 'static,
337    {
338        self.hooks.push(hook);
339        self
340    }
341}
342
343impl<M> AgentRunner<M>
344where
345    M: CompletionModel,
346{
347    /// Set the total model-call budget, including the initial call and every
348    /// retry or continuation. Zero emits no model calls; one permits only the
349    /// initial call. Exceeding the budget returns [`PromptError::MaxTurnsError`].
350    pub fn max_turns(mut self, max_turns: usize) -> Self {
351        self.max_turns = max_turns;
352        self
353    }
354
355    /// Set the per-call runtime [`ToolCallExtensions`] for this run.
356    ///
357    /// The extensions are threaded to every tool the agent executes, so tools
358    /// can read caller-provided values (auth tokens, session IDs, conversation
359    /// state, …) via [`Tool::call_with_extensions`](crate::tool::Tool::call_with_extensions)
360    /// without the model ever seeing them. Replaces any extensions already set.
361    pub fn tool_extensions(mut self, extensions: ToolCallExtensions) -> Self {
362        self.tool_extensions = extensions;
363        self
364    }
365
366    /// Set the chat history preceding the prompt. Passing explicit history
367    /// bypasses conversation memory for this run.
368    pub fn history<I, T>(mut self, history: I) -> Self
369    where
370        I: IntoIterator<Item = T>,
371        T: Into<Message>,
372    {
373        self.chat_history = Some(history.into_iter().map(Into::into).collect());
374        self
375    }
376
377    /// Execute up to `concurrency` tools at once (1 by default). Applies to
378    /// **both** the blocking [`run`](Self::run) and the streaming
379    /// [`stream`](Self::stream) paths.
380    ///
381    /// The resulting message history is the same in both paths regardless of
382    /// `concurrency`: final tool results are persisted in tool-call order. At
383    /// the default `concurrency` of 1 the two paths are fully in lock-step; with
384    /// `concurrency > 1` the tools run in parallel, so a `ToolCall`/`ToolResult`
385    /// **hook may fire in completion order** rather than call order — the
386    /// per-tool side effects interleave even though the final history does not.
387    ///
388    /// For the streaming path: the driver emits *all* of a turn's `ToolCall`
389    /// stream items eagerly (in call order) when the model turn commits, then —
390    /// only after the whole tool batch settles successfully — surfaces the
391    /// per-tool `ToolExecutionStart` and `ToolResult` stream items in **call
392    /// order** (never completion order), for the tools whose body actually ran.
393    /// The persisted message history is unchanged.
394    ///
395    /// A `concurrency` of 0 is clamped to 1; `0` and `1` both run a turn's tools
396    /// sequentially (the `buffer_unordered` path is used only at `concurrency > 1`).
397    pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
398        self.concurrency = concurrency.max(1);
399        self
400    }
401
402    /// Set the conversation id used to load and persist memory for this run.
403    pub fn conversation(mut self, id: impl Into<String>) -> Self {
404        self.conversation_id = Some(id.into());
405        self
406    }
407
408    /// Disable conversation memory for this run (no load, no save).
409    pub fn without_memory(mut self) -> Self {
410        self.memory = None;
411        self.conversation_id = None;
412        self
413    }
414
415    /// Set the retry budget for invalid tool-call recovery. Invalid tool-call
416    /// retries also consume the total model-call budget.
417    pub fn max_invalid_tool_call_retries(mut self, retries: usize) -> Self {
418        self.max_invalid_tool_call_retries = retries;
419        self
420    }
421
422    pub(crate) fn agent_name_or_default(&self) -> &str {
423        self.agent_name.as_deref().unwrap_or(UNKNOWN_AGENT_NAME)
424    }
425
426    /// Build the sans-IO [`AgentRun`] for this runner's configuration.
427    /// `history_override` replaces the configured chat history (e.g. with
428    /// memory-loaded history). Delegates to [`build_agent_run`] — the single
429    /// construction site shared with the streaming driver.
430    pub(crate) fn build_run(&self, history_override: Option<Vec<Message>>) -> AgentRun {
431        build_agent_run(
432            self.prompt.clone(),
433            self.max_turns,
434            self.max_invalid_tool_call_retries,
435            self.output_schema.as_ref(),
436            history_override.or_else(|| self.chat_history.clone()),
437            self.tool_choice.clone(),
438        )
439    }
440}
441
442/// Construct an [`AgentRun`] from explicit run configuration. The single place a
443/// run is built, so the blocking and streaming drivers configure runs
444/// identically.
445pub(crate) fn build_agent_run(
446    prompt: Message,
447    max_turns: usize,
448    max_invalid_tool_call_retries: usize,
449    output_schema: Option<&schemars::Schema>,
450    history: Option<Vec<Message>>,
451    tool_choice: Option<ToolChoice>,
452) -> AgentRun {
453    let mut run = AgentRun::new(prompt)
454        .max_turns(max_turns)
455        .max_invalid_tool_call_retries(max_invalid_tool_call_retries)
456        .with_output_validation(
457            output_schema.map(|schema| schema.as_value().clone()),
458            DEFAULT_OUTPUT_RETRIES,
459        );
460    if let Some(history) = history {
461        run = run.with_history(history);
462    }
463    if let Some(tool_choice) = tool_choice {
464        run = run.with_tool_choice(tool_choice);
465    }
466    run
467}
468
469/// Build (or adopt) the top-level `invoke_agent` span for a run, shared by the
470/// blocking and streaming drivers so the run-level span shape is defined once.
471///
472/// Returns the span plus whether it was newly created. When the caller is
473/// already inside a span we adopt it and report `false`, so the driver can avoid
474/// recording run-level usage onto a span it does not own (see the
475/// `created_agent_span` guard in both drivers' `Done` handling).
476pub(crate) fn acquire_agent_span(
477    agent_name: &str,
478    preamble: Option<&str>,
479) -> (tracing::Span, bool) {
480    if tracing::Span::current().is_disabled() {
481        let span = info_span!(
482            "invoke_agent",
483            gen_ai.operation.name = "invoke_agent",
484            gen_ai.agent.name = agent_name,
485            gen_ai.system_instructions = preamble,
486            gen_ai.prompt = tracing::field::Empty,
487            gen_ai.completion = tracing::field::Empty,
488            gen_ai.usage.input_tokens = tracing::field::Empty,
489            gen_ai.usage.output_tokens = tracing::field::Empty,
490            gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
491            gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
492            gen_ai.usage.tool_use_prompt_tokens = tracing::field::Empty,
493            gen_ai.usage.reasoning_tokens = tracing::field::Empty,
494        );
495        (span, true)
496    } else {
497        (tracing::Span::current(), false)
498    }
499}
500
501/// Outcome of firing the `CompletionCall` hook for a turn.
502pub(crate) enum CompletionCallOutcome {
503    /// Proceed, optionally applying a per-turn request patch (the merged patch
504    /// from every hook that contributed one).
505    Proceed(Option<RequestPatch>),
506    /// Terminate the run with this reason.
507    Terminate(String),
508}
509
510/// Fire the `CompletionCall` hook for a turn and resolve its [`Flow`]
511/// (fail-closed). Shared by the blocking and streaming drivers so this steering
512/// event fires identically on both; each driver surfaces `Terminate` in its own
513/// medium (a returned `Err` vs. a yielded error item). Across a [`HookStack`]
514/// the resolved flow is the merged patch of every contributing hook.
515pub(crate) async fn resolve_completion_call<M>(
516    hooks: &HookStack<M>,
517    ctx: &HookContext,
518    prompt: &Message,
519    history: &[Message],
520    turn: usize,
521) -> CompletionCallOutcome
522where
523    M: CompletionModel,
524{
525    match flow_into_completion_call(
526        hooks
527            .on_event(
528                ctx,
529                StepEvent::CompletionCall {
530                    prompt,
531                    history,
532                    turn,
533                },
534            )
535            .await,
536    ) {
537        CompletionCallDecision::Terminate(reason) => CompletionCallOutcome::Terminate(reason),
538        CompletionCallDecision::Patch(patch) => CompletionCallOutcome::Proceed(Some(patch)),
539        CompletionCallDecision::Proceed => CompletionCallOutcome::Proceed(None),
540    }
541}
542
543/// Append a finished run's messages to conversation memory, logging and
544/// proceeding on failure. Shared `Done`-arm behavior for both drivers.
545pub(crate) async fn append_run_messages(
546    memory_handle: Option<&(Arc<dyn ConversationMemory>, String)>,
547    messages: &[Message],
548) {
549    // Clone into an owned vec only when there is a backend to append to — the
550    // common no-memory path pays nothing.
551    if let Some((memory, id)) = memory_handle
552        && let Err(err) = memory.append(id, messages.to_vec()).await
553    {
554        tracing::warn!(
555            error = %err,
556            conversation_id = %id,
557            "conversation memory append failed; surfacing final response anyway"
558        );
559    }
560}
561
562/// Whether (and how) a tool call executed, for [`run_single_tool`].
563pub(crate) enum ToolExecution {
564    /// The tool's body ran. Carries the **effective** tool call — the model's
565    /// call with any [`Flow::RewriteArgs`](crate::agent::Flow::RewriteArgs) hook
566    /// rewrite applied — so the driver can surface it in the
567    /// [`ToolExecutionStart`](crate::agent::prompt_request::streaming::MultiTurnStreamItem::ToolExecutionStart)
568    /// event (what actually ran, not the model's original arguments). Boxed to
569    /// keep this enum small (a `ToolCall` is large next to the empty `Skipped`).
570    Executed(Box<ToolCall>),
571    /// A `ToolCall` hook returned [`Flow::Skip`](crate::agent::Flow::Skip): the
572    /// body did not run, so no execution-start is surfaced — but the skip result
573    /// is still delivered to the model (and surfaced as a `ToolResult`).
574    Skipped,
575}
576
577/// Outcome of [`run_single_tool`]: the tool-result content plus whether the
578/// tool's body ran (and the effective call) or a hook skipped it.
579pub(crate) struct ToolCallOutcome {
580    /// The tool result delivered to the model (a real output, a redacted
581    /// replacement, or a hook skip reason).
582    pub content: UserContent,
583    /// How the call resolved: executed (with the effective tool call) or skipped.
584    pub execution: ToolExecution,
585}
586
587/// Execute a single tool call, firing the `ToolCall` and `ToolResult` hooks and
588/// shaping the result. **Shared by the blocking and streaming drivers** so a
589/// tool call behaves identically in both: same hook events, same fail-closed
590/// skip/terminate handling, and the same result shaping — a hook skip reason is
591/// emitted verbatim ([`tool_result_message`]) while a real tool output is parsed
592/// ([`tool_result_output`]). Records `gen_ai.tool.*` on the current span;
593/// `error_history` builds a cancellation error if a hook terminates the run.
594/// Returns whether the tool body executed via [`ToolCallOutcome::execution`].
595pub(crate) async fn run_single_tool<M>(
596    hooks: &HookStack<M>,
597    ctx: &HookContext,
598    tool_server: &ToolServerHandle,
599    tool_extensions: &ToolCallExtensions,
600    tool_call: &ToolCall,
601    internal_call_id: &str,
602    error_history: &[Message],
603) -> Result<ToolCallOutcome, PromptError>
604where
605    M: CompletionModel,
606{
607    let tool_name = &tool_call.function.name;
608    // `mut` so a `Flow::RewriteArgs` hook can rewrite the arguments the tool
609    // runs with (the model's emitted arguments are otherwise used verbatim).
610    let mut args = json_utils::value_to_json_string(&tool_call.function.arguments);
611
612    let tool_span = tracing::Span::current();
613    tool_span.record("gen_ai.tool.name", tool_name);
614    tool_span.record("gen_ai.tool.call.id", &tool_call.id);
615    tool_span.record("gen_ai.tool.call.arguments", &args);
616
617    // Resolve the `ToolCall` hook chain. A proceeding chain carries any
618    // `Flow::RewriteArgs` in the flow itself (→ `ProceedWith`); a chain that a
619    // later hook short-circuits with `Skip`/`Terminate` salvages the accumulated
620    // rewrite into `salvaged_rewrite` so it is *not* lost — the rewritten args
621    // must still be reported on the skipped `ToolResult` and in tracing rather
622    // than leaking the model's original args (see [`HookStack::resolve_tool_call`]).
623    let (flow, salvaged_rewrite) = hooks
624        .resolve_tool_call(
625            ctx,
626            tool_name,
627            tool_call.call_id.as_deref(),
628            internal_call_id,
629            &args,
630        )
631        .await;
632
633    // Apply a salvaged rewrite (short-circuit path only) so `args` — what the
634    // `ToolResult` reports — and the span reflect the effective arguments.
635    if let Some(rewritten) = salvaged_rewrite.as_ref() {
636        args = json_utils::value_to_json_string(rewritten);
637        tool_span.record("gen_ai.tool.call.arguments", &args);
638        tracing::debug!(
639            tool_name = tool_name,
640            "tool-call arguments rewritten by a hook"
641        );
642    }
643
644    // On `Skip` the body does not run and the structured outcome is `Skipped`;
645    // otherwise the tool executes into a structured `ToolExecutionResult`.
646    // `effective_args` is what the tool actually ran with (the model's, a hook's
647    // `RewriteArgs` replacement, or a salvaged rewrite) — surfaced in the
648    // execution-start event so a redaction rewrite does not leak. Unused for a skip.
649    let mut skipped: Option<ToolExecutionResult> = None;
650    let effective_args: serde_json::Value = match flow_into_tool_call(flow) {
651        ToolCallDecision::Terminate(reason) => {
652            return Err(PromptError::prompt_cancelled(
653                error_history.to_vec(),
654                reason,
655            ));
656        }
657        ToolCallDecision::Skip(reason) => {
658            tracing::info!(tool_name = tool_name, reason = reason, "Tool call rejected");
659            // Synthetic rejection: `Skipped` outcome, message delivered verbatim.
660            // Still fires the `ToolResult` hook so a policy observes the skip.
661            skipped = Some(ToolExecutionResult::skipped(reason));
662            // A skip runs nothing; its effective args are the salvaged rewrite
663            // (if any) so tracing/history stay consistent, though they go unused.
664            salvaged_rewrite.unwrap_or_else(|| tool_call.function.arguments.clone())
665        }
666        ToolCallDecision::ProceedWith(replacement) => {
667            // Proceeding rewrite: re-record the span so the trace, and the
668            // downstream `ToolResult` event, reflect what the tool actually
669            // received rather than what the model emitted.
670            args = json_utils::value_to_json_string(&replacement);
671            tool_span.record("gen_ai.tool.call.arguments", &args);
672            tracing::debug!(
673                tool_name = tool_name,
674                "tool-call arguments rewritten by a hook"
675            );
676            replacement
677        }
678        ToolCallDecision::Proceed => tool_call.function.arguments.clone(),
679    };
680
681    // Resolve the structured execution result and how the call surfaced. A skip
682    // produces no execution-start event; a real execution carries the effective
683    // tool call (the model's call with any `RewriteArgs` applied).
684    let (exec, execution) = match skipped {
685        Some(exec) => (exec, ToolExecution::Skipped),
686        None => {
687            let mut effective_tool_call = tool_call.clone();
688            effective_tool_call.function.arguments = effective_args;
689            let exec = tool_server
690                .call_tool_structured(tool_name, &args, tool_extensions)
691                .await;
692            (exec, ToolExecution::Executed(Box::new(effective_tool_call)))
693        }
694    };
695    // A synthetic (skip) result is delivered verbatim; a real tool output is
696    // parsed (it may be multimodal).
697    let synthetic = matches!(execution, ToolExecution::Skipped);
698
699    // The tool's raw output/outcome are deliberately NOT recorded on the span
700    // yet: a `ToolResult` hook may redact or terminate first. Recording is
701    // deferred until after the hook runs — the redacted replacement on `Replace`,
702    // the raw output on `Keep`, and nothing on `Terminate` — so a redaction
703    // guardrail never leaks the original (raw output or error message) via the
704    // trace. (OpenAI Agents applies tool-output guardrails before tracing /
705    // tool-end / model-visible output for the same reason.) The hook still
706    // observes the tool's actual output via `result` and the structured
707    // classification via `outcome` / `extensions`.
708    match flow_into_tool_result(
709        hooks
710            .on_event(
711                ctx,
712                StepEvent::ToolResult {
713                    tool_name,
714                    tool_call_id: tool_call.call_id.as_deref(),
715                    internal_call_id,
716                    // The first result hook observes the tool's actual output,
717                    // before any `RewriteResult` replacement is applied below.
718                    args: &args,
719                    result: &exec.model_output,
720                    outcome: &exec.outcome,
721                    extensions: &exec.extensions,
722                },
723            )
724            .await,
725    ) {
726        ToolResultDecision::Terminate(reason) => {
727            // Do not record or log the raw output/outcome: the model never sees it
728            // (the run is terminating) and a result hook may have terminated to
729            // prevent exactly that leak.
730            tracing::info!("tool {tool_name} with args {args}; run terminated by a result hook");
731            Err(PromptError::prompt_cancelled(
732                error_history.to_vec(),
733                reason,
734            ))
735        }
736        ToolResultDecision::Replace(replacement) => {
737            // The hook replaced the model-visible result. Record the outcome and
738            // the replacement (the raw output was never recorded on the span
739            // before the hook ran) and log only that a rewrite happened — never
740            // the tool's raw output — so a redaction hook does not leak the
741            // original via the trace or the log. The replacement is hook-supplied
742            // content, so it is delivered verbatim (like a `Skip` reason via
743            // [`tool_result_message`]) rather than re-parsed as tool output.
744            record_tool_outcome(&tool_span, &exec.outcome);
745            tool_span.record("gen_ai.tool.call.result", &replacement);
746            tracing::info!("tool {tool_name} with args {args}; result rewritten by a hook");
747            Ok(ToolCallOutcome {
748                content: tool_result_message(
749                    tool_call.id.clone(),
750                    tool_call.call_id.clone(),
751                    replacement,
752                ),
753                execution,
754            })
755        }
756        ToolResultDecision::Keep => {
757            // No redaction requested: now that the hook has run without replacing
758            // the output, record the outcome and the tool's real result.
759            record_tool_outcome(&tool_span, &exec.outcome);
760            tool_span.record("gen_ai.tool.call.result", &exec.model_output);
761            if synthetic {
762                tracing::info!(
763                    "tool {tool_name} skipped by a hook; result: {}",
764                    exec.model_output
765                );
766            } else {
767                tracing::info!(
768                    "executed tool {tool_name} with args {args}. outcome: {}; result: {}",
769                    exec.outcome.as_str(),
770                    exec.model_output
771                );
772            }
773            let content = if synthetic {
774                tool_result_message(
775                    tool_call.id.clone(),
776                    tool_call.call_id.clone(),
777                    exec.model_output,
778                )
779            } else {
780                tool_result_output(
781                    tool_call.id.clone(),
782                    tool_call.call_id.clone(),
783                    exec.model_output,
784                )
785            };
786            Ok(ToolCallOutcome { content, execution })
787        }
788    }
789}
790
791/// Record the structured tool [`ToolOutcome`] onto the `execute_tool` span. Kept
792/// separate from `gen_ai.tool.call.result` so it can be recorded (post-hook,
793/// alongside the model-visible result) without being part of the raw-output
794/// redaction surface — the outcome kind is a classification, not sensitive
795/// output.
796fn record_tool_outcome(span: &tracing::Span, outcome: &ToolOutcome) {
797    span.record("gen_ai.tool.call.outcome", outcome.as_str());
798    if let ToolOutcome::Error(failure) = outcome {
799        span.record("gen_ai.tool.error.type", failure.kind.as_str());
800    }
801}
802
803/// Build the per-tool `execute_tool` span carrying the `gen_ai.tool.*` fields
804/// that [`run_single_tool`] records on the current span. Parented to the
805/// contextual current span; the blocking driver additionally chains it via
806/// `follows_from`, while the streaming driver uses it as-is. Shared by both
807/// drivers so the span shape stays defined in one place.
808pub(crate) fn new_execute_tool_span() -> tracing::Span {
809    info_span!(
810        "execute_tool",
811        gen_ai.operation.name = "execute_tool",
812        gen_ai.tool.type = "function",
813        gen_ai.tool.name = tracing::field::Empty,
814        gen_ai.tool.call.id = tracing::field::Empty,
815        gen_ai.tool.call.arguments = tracing::field::Empty,
816        gen_ai.tool.call.result = tracing::field::Empty,
817        gen_ai.tool.call.outcome = tracing::field::Empty,
818        gen_ai.tool.error.type = tracing::field::Empty
819    )
820}
821
822/// [`TurnSource`] for the blocking surface: each turn issues a unary
823/// `model.completion()` request and feeds the whole response into the machine.
824/// Emits no intermediate items (the blocking surface folds the engine to its
825/// final response), but keeps the blocking driver's linear `follows_from` span
826/// chain across chat and tool spans.
827pub(crate) struct UnaryTurnSource {
828    /// Sequences chat and tool spans into a linear `follows_from` chain (the
829    /// streaming surface parents into a tree instead and does not chain).
830    ///
831    /// Atomic rather than `Cell` despite being driven by a single sequential
832    /// task: `run_tool_calls` passes `chain_span` as a closure into
833    /// `drive_tool_calls`, whose returned `DriveStream` is `Send`. That makes the
834    /// closure capture `&self`, so `&UnaryTurnSource` must be `Send`, i.e.
835    /// `UnaryTurnSource: Sync` — which `AtomicU64` provides and `Cell` does not.
836    current_span_id: AtomicU64,
837}
838
839impl UnaryTurnSource {
840    pub(crate) fn new() -> Self {
841        Self {
842            current_span_id: AtomicU64::new(0),
843        }
844    }
845
846    /// Chain `span` onto the previous step's span and record it as the new chain
847    /// head, preserving the blocking driver's linear causal trace.
848    fn chain_span(&self, span: tracing::Span) -> tracing::Span {
849        let span = match self.current_span_id.load(Ordering::Relaxed) {
850            0 => span,
851            id => span.follows_from(Id::from_u64(id)).to_owned(),
852        };
853        if let Some(id) = span.id() {
854            self.current_span_id.store(id.into_u64(), Ordering::Relaxed);
855        }
856        span
857    }
858}
859
860impl<M> TurnSource<M> for UnaryTurnSource
861where
862    M: CompletionModel,
863{
864    type Raw = M::Response;
865
866    fn open_chat_span(
867        &self,
868        runner: &AgentRunner<M>,
869        effective_preamble: Option<&str>,
870    ) -> tracing::Span {
871        let chat_span = build_chat_span!(runner, effective_preamble, "chat");
872        self.chain_span(chat_span)
873    }
874
875    fn run_model_turn<'a>(
876        &'a mut self,
877        runner: &'a AgentRunner<M>,
878        hook_ctx: &'a HookContext,
879        run: &'a mut AgentRun,
880        prepared: PreparedCompletionRequest<M>,
881        chat_span: tracing::Span,
882        _agent_span: &'a tracing::Span,
883        current_prompt: Message,
884    ) -> DriveStream<'a, M::Response> {
885        Box::pin(async_stream::stream! {
886            let resp = match prepared.builder.send().instrument(chat_span.clone()).await {
887                Ok(resp) => resp,
888                Err(err) => {
889                    yield Err(StreamingError::from(err));
890                    return;
891                }
892            };
893
894            let mut outcome = match run.model_response(ModelTurn::new(
895                resp.message_id.clone(),
896                resp.choice.clone(),
897                resp.usage,
898                prepared.executable_tool_names,
899                prepared.allowed_tool_names,
900            )) {
901                Ok(outcome) => outcome,
902                Err(err) => {
903                    yield Err(Box::new(err).into());
904                    return;
905                }
906            };
907
908            loop {
909                match outcome {
910                    ModelTurnOutcome::NeedsResolution(context) => {
911                        let flow = runner
912                            .hooks
913                            .on_event(hook_ctx, StepEvent::InvalidToolCall(&context))
914                            .await;
915                        match flow_into_invalid(flow) {
916                            InvalidDecision::Terminate(reason) => {
917                                yield Err(StreamingError::Prompt(Box::new(
918                                    run.cancel_error(reason),
919                                )));
920                                return;
921                            }
922                            InvalidDecision::Action(action) => {
923                                outcome = match run.resolve_invalid_tool_call(action) {
924                                    Ok(outcome) => outcome,
925                                    Err(err) => {
926                                        yield Err(Box::new(err).into());
927                                        return;
928                                    }
929                                };
930                            }
931                        }
932                    }
933                    ModelTurnOutcome::TurnRetried => break,
934                    ModelTurnOutcome::Continue {
935                        response_hook_suppressed,
936                    } => {
937                        if !response_hook_suppressed {
938                            // The medium-specific raw response event fires first,
939                            // then the normalized per-turn event. Both are
940                            // observe-only and suppressed for recovered turns.
941                            if let Some(reason) = observe_flow(
942                                runner
943                                    .hooks
944                                    .on_event(hook_ctx, StepEvent::CompletionResponse {
945                                        prompt: &current_prompt,
946                                        response: &resp,
947                                    })
948                                    .await,
949                            ) {
950                                yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
951                                return;
952                            }
953                            if let Some(reason) = observe_flow(
954                                runner
955                                    .hooks
956                                    .on_event(hook_ctx, StepEvent::ModelTurnFinished {
957                                        turn: hook_ctx.turn(),
958                                        content: &resp.choice,
959                                        usage: resp.usage,
960                                    })
961                                    .await,
962                            ) {
963                                yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
964                                return;
965                            }
966                        }
967                        break;
968                    }
969                }
970            }
971        })
972    }
973
974    fn run_tool_calls<'a>(
975        &'a self,
976        runner: &'a AgentRunner<M>,
977        hook_ctx: &'a HookContext,
978        run: &'a mut AgentRun,
979        calls: Vec<PendingToolCall>,
980    ) -> DriveStream<'a, M::Response> {
981        // The blocking surface chains tool spans into its linear `follows_from`
982        // sequence (chat -> tool -> chat), and discards the yielded items, so it
983        // skips building them.
984        drive_tool_calls(
985            runner,
986            hook_ctx,
987            run,
988            calls,
989            |span| self.chain_span(span),
990            false,
991        )
992    }
993
994    fn record_run_level_telemetry(
995        &self,
996        agent_span: &tracing::Span,
997        response: &PromptResponse,
998        created_agent_span: bool,
999    ) {
1000        // Record run-level completion + usage onto the agent span, but only when
1001        // we created it — never pollute a caller-supplied outer span. The usage
1002        // fields go through the same recorder the streaming surface uses; the
1003        // blocking surface additionally records the final completion text.
1004        if created_agent_span {
1005            agent_span.record("gen_ai.completion", &response.output);
1006            record_usage_on_span(agent_span, response.usage);
1007        }
1008    }
1009
1010    fn final_item(&self, _response: &PromptResponse) -> Option<MultiTurnStreamItem<M::Response>> {
1011        // The blocking surface folds the engine and discards the final item, so
1012        // building it (an extra full-response clone) is skipped entirely.
1013        None
1014    }
1015}
1016
1017impl<M> AgentRunner<M>
1018where
1019    M: CompletionModel,
1020{
1021    /// Drive the agent loop to completion, returning the aggregated
1022    /// [`PromptResponse`]. Hooks fire at every observable point; the first hook
1023    /// to terminate cancels the run.
1024    pub async fn run(self) -> Result<PromptResponse, PromptError> {
1025        let (agent_span, created_agent_span) =
1026            acquire_agent_span(self.agent_name_or_default(), self.preamble.as_deref());
1027
1028        if let Some(text) = self.prompt.rag_text() {
1029            agent_span.record("gen_ai.prompt", text);
1030        }
1031
1032        // When the caller passes explicit history, memory is fully bypassed for
1033        // this run (no load AND no save). Otherwise, if a memory backend and
1034        // conversation id are both configured, load prior history.
1035        let (history_override, memory_handle) = match &self.chat_history {
1036            Some(_) => (None, None),
1037            None => match (&self.memory, &self.conversation_id) {
1038                (Some(memory), Some(id)) => {
1039                    let loaded = memory.load(id).await?;
1040                    (Some(loaded), Some((memory.clone(), id.clone())))
1041                }
1042                _ => (None, None),
1043            },
1044        };
1045
1046        let run = self.build_run(history_override);
1047
1048        // Fold the shared engine to its final response. The blocking surface
1049        // uses a unary model transport and ignores the intermediate items the
1050        // engine yields; the engine is driven under the caller's ambient span
1051        // (no `instrument`), keeping the agent span detached and the chat/tool
1052        // spans on the blocking `follows_from` chain.
1053        let driver = drive_agent(
1054            self,
1055            UnaryTurnSource::new(),
1056            run,
1057            agent_span,
1058            created_agent_span,
1059            memory_handle,
1060            false,
1061        );
1062        futures::pin_mut!(driver);
1063
1064        let mut response = None;
1065        while let Some(item) = driver.next().await {
1066            match item {
1067                Ok(DriveItem::Done(done)) => response = Some(*done),
1068                Ok(DriveItem::Item(_)) => {}
1069                Err(err) => return Err(streaming_error_into_prompt(err)),
1070            }
1071        }
1072
1073        // The engine yields `Done` unless it errored (handled above).
1074        response.ok_or_else(|| {
1075            PromptError::CompletionError(CompletionError::ResponseError(
1076                "agent run ended without producing a final response".to_string(),
1077            ))
1078        })
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use std::sync::{
1085        Arc, Mutex,
1086        atomic::{AtomicU32, Ordering::SeqCst},
1087    };
1088
1089    use futures::StreamExt;
1090    use serde_json::json;
1091
1092    use crate::agent::AgentBuilder;
1093    use crate::agent::hook::{
1094        AgentHook, Flow, HookContext, RequestPatch, StepEvent, StepEventKind,
1095    };
1096    use crate::agent::prompt_request::streaming::{MultiTurnStreamItem, StreamingError};
1097    use crate::agent::run::OutputMode;
1098    use crate::completion::{CompletionModel, Message, Prompt, PromptError};
1099    use crate::message::{AssistantContent, ToolCall, ToolChoice, ToolFunction, UserContent};
1100    use crate::streaming::{StreamedAssistantContent, StreamedUserContent, StreamingPrompt};
1101    use crate::test_utils::{
1102        MockAddTool, MockBarrierTool, MockCompletionModel, MockOperationArgs, MockStreamEvent,
1103        MockSubtractTool, MockToolError, MockTurn,
1104    };
1105    use crate::tool::Tool;
1106
1107    /// Records the kind of every hook event (and every tool-result payload) so a
1108    /// run() and a stream() of the same scenario can be compared.
1109    #[derive(Clone, Default)]
1110    struct RecordingHook {
1111        events: Arc<Mutex<Vec<StepEventKind>>>,
1112        tool_results: Arc<Mutex<Vec<String>>>,
1113    }
1114
1115    impl RecordingHook {
1116        /// Event kinds that should be identical across streaming and
1117        /// non-streaming (excludes the medium-specific delta / response-finish
1118        /// events).
1119        fn shared_events(&self) -> Vec<StepEventKind> {
1120            self.events
1121                .lock()
1122                .expect("events lock")
1123                .iter()
1124                .copied()
1125                .filter(|kind| {
1126                    matches!(
1127                        kind,
1128                        StepEventKind::CompletionCall
1129                            | StepEventKind::ToolCall
1130                            | StepEventKind::ToolResult
1131                            | StepEventKind::InvalidToolCall
1132                    )
1133                })
1134                .collect()
1135        }
1136
1137        fn tool_results(&self) -> Vec<String> {
1138            self.tool_results.lock().expect("results lock").clone()
1139        }
1140
1141        /// Count of a single event kind across the whole run, including the
1142        /// medium-specific response-finish events that `shared_events` excludes.
1143        fn count(&self, kind: StepEventKind) -> usize {
1144            self.events
1145                .lock()
1146                .expect("events lock")
1147                .iter()
1148                .filter(|recorded| **recorded == kind)
1149                .count()
1150        }
1151    }
1152
1153    impl<M: CompletionModel> AgentHook<M> for RecordingHook {
1154        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1155            self.events.lock().expect("events lock").push(event.kind());
1156            if let StepEvent::ToolResult { result, .. } = event {
1157                self.tool_results
1158                    .lock()
1159                    .expect("results lock")
1160                    .push(result.to_string());
1161            }
1162            Flow::cont()
1163        }
1164    }
1165
1166    fn blocking_model() -> MockCompletionModel {
1167        MockCompletionModel::from_turns([
1168            MockTurn::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
1169            MockTurn::text("the answer is 5"),
1170        ])
1171    }
1172
1173    fn streaming_model() -> MockCompletionModel {
1174        MockCompletionModel::from_stream_turns([
1175            vec![
1176                MockStreamEvent::tool_call_name_delta("tc1", "ic1", "add"),
1177                MockStreamEvent::tool_call_arguments_delta("tc1", "ic1", "{\"x\":2,\"y\":3}"),
1178                MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
1179                MockStreamEvent::final_response_with_total_tokens(0),
1180            ],
1181            vec![
1182                MockStreamEvent::text("the answer is 5"),
1183                MockStreamEvent::final_response_with_total_tokens(0),
1184            ],
1185        ])
1186    }
1187
1188    /// `AgentRunner::from_agent` preserves the distinction between an absent
1189    /// agent default (the implicit one-call budget) and an explicit zero budget.
1190    #[tokio::test]
1191    async fn from_agent_preserves_implicit_one_and_explicit_zero_budgets() {
1192        let implicit_model = blocking_model();
1193        let implicit_recorded = implicit_model.clone();
1194        let implicit_agent = AgentBuilder::new(implicit_model).tool(MockAddTool).build();
1195        let implicit_runner = super::AgentRunner::from_agent(&implicit_agent, "add 2 and 3");
1196        assert_eq!(implicit_runner.max_turns, 1);
1197
1198        let implicit_err = implicit_runner
1199            .run()
1200            .await
1201            .expect_err("implicit budget should reject the second model call");
1202        assert!(matches!(
1203            implicit_err,
1204            PromptError::MaxTurnsError { max_turns: 1, .. }
1205        ));
1206        assert_eq!(implicit_recorded.request_count(), 1);
1207
1208        let zero_model = MockCompletionModel::text("should not be requested");
1209        let zero_recorded = zero_model.clone();
1210        let zero_agent = AgentBuilder::new(zero_model).default_max_turns(0).build();
1211        let zero_runner = super::AgentRunner::from_agent(&zero_agent, "do not call");
1212        assert_eq!(zero_runner.max_turns, 0);
1213
1214        let zero_err = zero_runner
1215            .run()
1216            .await
1217            .expect_err("explicit zero budget should reject the initial model call");
1218        assert!(matches!(
1219            zero_err,
1220            PromptError::MaxTurnsError { max_turns: 0, .. }
1221        ));
1222        assert_eq!(zero_recorded.request_count(), 0);
1223    }
1224
1225    /// The public blocking and streaming prompt surfaces enforce the one-call
1226    /// boundary identically after executing a tool-producing first turn.
1227    #[tokio::test]
1228    async fn prompt_surfaces_reject_second_tool_roundtrip_request_at_budget_one() {
1229        let blocking_model = blocking_model();
1230        let blocking_recorded = blocking_model.clone();
1231        let blocking_agent = AgentBuilder::new(blocking_model).tool(MockAddTool).build();
1232        let blocking_err = blocking_agent
1233            .prompt("add 2 and 3")
1234            .max_turns(1)
1235            .await
1236            .expect_err("blocking prompt should reject request two");
1237        assert!(matches!(
1238            blocking_err,
1239            PromptError::MaxTurnsError { max_turns: 1, .. }
1240        ));
1241        assert_eq!(blocking_recorded.request_count(), 1);
1242
1243        let streaming_model = streaming_model();
1244        let streaming_recorded = streaming_model.clone();
1245        let streaming_agent = AgentBuilder::new(streaming_model).tool(MockAddTool).build();
1246        let mut stream = streaming_agent
1247            .stream_prompt("add 2 and 3")
1248            .max_turns(1)
1249            .await;
1250        let mut streaming_err = None;
1251        while let Some(item) = stream.next().await {
1252            if let Err(err) = item {
1253                streaming_err = Some(err);
1254                break;
1255            }
1256        }
1257        match streaming_err {
1258            Some(StreamingError::Prompt(err)) => assert!(matches!(
1259                *err,
1260                PromptError::MaxTurnsError { max_turns: 1, .. }
1261            )),
1262            other => panic!("expected streaming max-turns error, got {other:?}"),
1263        }
1264        assert_eq!(streaming_recorded.request_count(), 1);
1265    }
1266
1267    /// run() and stream() of the same tool-calling scenario produce the same
1268    /// final output, the same final message history, the same tool-result
1269    /// content, and the same medium-independent hook event sequence.
1270    #[tokio::test]
1271    async fn run_and_stream_behave_identically_for_a_tool_call() {
1272        let blocking_hook = RecordingHook::default();
1273        let blocking = AgentBuilder::new(blocking_model())
1274            .tool(MockAddTool)
1275            .build()
1276            .runner("add 2 and 3")
1277            .max_turns(2)
1278            .add_hook(blocking_hook.clone())
1279            .run()
1280            .await
1281            .expect("blocking run should succeed");
1282
1283        // No `.with_history` on either runner — `stream()` must return the final
1284        // history just like `run()` returns `messages`.
1285        let streaming_hook = RecordingHook::default();
1286        let mut stream = AgentBuilder::new(streaming_model())
1287            .tool(MockAddTool)
1288            .build()
1289            .runner("add 2 and 3")
1290            .max_turns(2)
1291            .add_hook(streaming_hook.clone())
1292            .stream()
1293            .await;
1294
1295        let mut final_response = None;
1296        while let Some(item) = stream.next().await {
1297            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
1298                item.map_err(|err| panic!("stream item errored: {err}"))
1299            {
1300                final_response = Some(resp);
1301            }
1302        }
1303        let final_response = final_response.expect("stream should yield a final response");
1304
1305        // Same final output.
1306        assert_eq!(blocking.output, "the answer is 5");
1307        assert_eq!(final_response.output(), blocking.output);
1308
1309        // Same medium-independent hook event sequence (model call, tool call,
1310        // tool result, second model call).
1311        assert_eq!(
1312            blocking_hook.shared_events(),
1313            streaming_hook.shared_events()
1314        );
1315        assert_eq!(
1316            blocking_hook.shared_events(),
1317            vec![
1318                StepEventKind::CompletionCall,
1319                StepEventKind::ToolCall,
1320                StepEventKind::ToolResult,
1321                StepEventKind::CompletionCall,
1322            ]
1323        );
1324
1325        // Same tool-result content seen by the hook.
1326        assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
1327        assert_eq!(blocking_hook.tool_results(), vec!["5".to_string()]);
1328
1329        // Same final message history (compared via serialized form to normalize).
1330        let blocking_messages = blocking.messages.expect("blocking messages");
1331        let streaming_messages = final_response
1332            .messages()
1333            .expect("streaming history")
1334            .to_vec();
1335        assert_eq!(
1336            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
1337            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
1338        );
1339    }
1340
1341    /// Structured tool-execution results reach `StepEvent::ToolResult` as machine
1342    /// metadata (outcome + extensions), on both the blocking and streaming paths,
1343    /// so hooks can steer on a classified failure without parsing the result
1344    /// string.
1345    mod structured_tool_results {
1346        use std::sync::{Arc, Mutex};
1347
1348        use futures::StreamExt;
1349        use serde_json::json;
1350
1351        use crate::agent::{AgentBuilder, AgentHook, Flow, HookContext, HookStack, StepEvent};
1352        use crate::completion::CompletionModel;
1353        use crate::test_utils::{
1354            MockAddTool, MockCompletionModel, MockDeniedTool, MockFailingTool,
1355            MockHandledFailureTool, MockMetadataTool, MockRequestId, MockStreamEvent, MockTurn,
1356        };
1357        use crate::tool::{ToolFailureKind, ToolOutcome};
1358
1359        /// Records, for every `ToolResult` event, a compact outcome label and the
1360        /// model-visible result string — the machine metadata a policy reads.
1361        #[derive(Clone, Default)]
1362        struct OutcomeHook {
1363            outcomes: Arc<Mutex<Vec<String>>>,
1364            results: Arc<Mutex<Vec<String>>>,
1365        }
1366
1367        impl OutcomeHook {
1368            fn outcomes(&self) -> Vec<String> {
1369                self.outcomes.lock().expect("outcomes").clone()
1370            }
1371
1372            fn results(&self) -> Vec<String> {
1373                self.results.lock().expect("results").clone()
1374            }
1375        }
1376
1377        /// A compact string label for an outcome, e.g. `error:timeout`.
1378        fn outcome_label(outcome: &ToolOutcome) -> String {
1379            match outcome {
1380                ToolOutcome::Success => "success".to_string(),
1381                ToolOutcome::Error(failure) => format!("error:{}", failure.kind.as_str()),
1382                ToolOutcome::Skipped => "skipped".to_string(),
1383                ToolOutcome::Denied => "denied".to_string(),
1384            }
1385        }
1386
1387        impl<M: CompletionModel> AgentHook<M> for OutcomeHook {
1388            async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1389                if let StepEvent::ToolResult {
1390                    result, outcome, ..
1391                } = event
1392                {
1393                    self.outcomes
1394                        .lock()
1395                        .expect("outcomes")
1396                        .push(outcome_label(outcome));
1397                    self.results
1398                        .lock()
1399                        .expect("results")
1400                        .push(result.to_string());
1401                }
1402                Flow::cont()
1403            }
1404        }
1405
1406        /// A blocking model that calls `tool` once, then answers.
1407        fn model_one_tool_then_text(tool: &str) -> MockCompletionModel {
1408            MockCompletionModel::from_turns([
1409                MockTurn::tool_call("tc1", tool, json!({})),
1410                MockTurn::text("done"),
1411            ])
1412        }
1413
1414        /// A streaming model that calls `tool` once, then answers.
1415        fn stream_model_one_tool_then_text(tool: &str) -> MockCompletionModel {
1416            MockCompletionModel::from_stream_turns([
1417                vec![
1418                    MockStreamEvent::tool_call_name_delta("tc1", "ic1", tool),
1419                    MockStreamEvent::tool_call_arguments_delta("tc1", "ic1", "{}"),
1420                    MockStreamEvent::tool_call("tc1", tool, json!({})),
1421                    MockStreamEvent::final_response_with_total_tokens(0),
1422                ],
1423                vec![
1424                    MockStreamEvent::text("done"),
1425                    MockStreamEvent::final_response_with_total_tokens(0),
1426                ],
1427            ])
1428        }
1429
1430        // (1) A `Timeout` failure reaches `StepEvent::ToolResult` as structured
1431        // metadata (not just a string), with the model-visible feedback intact.
1432        #[tokio::test]
1433        async fn timeout_failure_surfaces_structured_outcome() {
1434            let hook = OutcomeHook::default();
1435            AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1436                .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1437                .add_hook(hook.clone())
1438                .build()
1439                .runner("go")
1440                .max_turns(3)
1441                .run()
1442                .await
1443                .expect("run should succeed; a tool timeout is model-visible feedback, not fatal");
1444
1445            assert_eq!(hook.outcomes(), vec!["error:timeout".to_string()]);
1446            // (4) The model still receives useful text for the handled failure.
1447            assert_eq!(hook.results(), vec!["mock tool call failed".to_string()]);
1448        }
1449
1450        // (2) A hook counts timeout failures in the run scratchpad and terminates
1451        // the run after a threshold — the motivating use case.
1452        #[tokio::test]
1453        async fn hook_terminates_after_repeated_timeouts() {
1454            #[derive(Clone, Default)]
1455            struct TimeoutCount(usize);
1456
1457            struct TimeoutTerminator;
1458            impl<M: CompletionModel> AgentHook<M> for TimeoutTerminator {
1459                async fn on_event(&self, ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1460                    if let StepEvent::ToolResult { outcome, .. } = event
1461                        && outcome.is_error_kind(ToolFailureKind::Timeout)
1462                    {
1463                        let count = ctx.scratchpad().update(|c: &mut TimeoutCount| {
1464                            c.0 += 1;
1465                            c.0
1466                        });
1467                        if count >= 2 {
1468                            return Flow::terminate("aborting after repeated tool timeouts");
1469                        }
1470                    }
1471                    Flow::cont()
1472                }
1473            }
1474
1475            let observer = OutcomeHook::default();
1476            let err = AgentBuilder::new(MockCompletionModel::from_turns([
1477                MockTurn::tool_call("tc1", "flaky_tool", json!({})),
1478                MockTurn::tool_call("tc2", "flaky_tool", json!({})),
1479                MockTurn::text("unreachable"),
1480            ]))
1481            .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1482            // Observer first so it records both timeouts before the terminator fires.
1483            .add_hook(observer.clone())
1484            .add_hook(TimeoutTerminator)
1485            .build()
1486            .runner("go")
1487            .max_turns(5)
1488            .run()
1489            .await
1490            .expect_err("the run must terminate after two timeouts");
1491
1492            assert!(
1493                err.to_string()
1494                    .contains("aborting after repeated tool timeouts"),
1495                "unexpected error: {err}"
1496            );
1497            assert_eq!(
1498                observer.outcomes(),
1499                vec!["error:timeout".to_string(), "error:timeout".to_string()],
1500                "both timeout outcomes must be observed before termination"
1501            );
1502        }
1503
1504        // (3) A not-found (404) failure surfaces as structured `NotFound` metadata
1505        // but does not terminate the run by default — the model may try another path.
1506        #[tokio::test]
1507        async fn not_found_outcome_is_structured_and_non_fatal() {
1508            let hook = OutcomeHook::default();
1509            let status: Arc<Mutex<Option<u16>>> = Arc::new(Mutex::new(None));
1510
1511            struct StatusProbe(Arc<Mutex<Option<u16>>>);
1512            impl<M: CompletionModel> AgentHook<M> for StatusProbe {
1513                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1514                    if let StepEvent::ToolResult { outcome, .. } = event
1515                        && let ToolOutcome::Error(failure) = outcome
1516                    {
1517                        *self.0.lock().expect("status") = failure.http_status;
1518                    }
1519                    Flow::cont()
1520                }
1521            }
1522
1523            AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1524                .tool(MockFailingTool::new(ToolFailureKind::NotFound))
1525                .add_hook(hook.clone())
1526                .add_hook(StatusProbe(status.clone()))
1527                .build()
1528                .runner("go")
1529                .max_turns(3)
1530                .run()
1531                .await
1532                .expect("a 404 must not terminate the run by default");
1533
1534            assert_eq!(hook.outcomes(), vec!["error:not_found".to_string()]);
1535            assert_eq!(
1536                *status.lock().expect("status"),
1537                Some(404),
1538                "the structured failure must carry the HTTP status"
1539            );
1540        }
1541
1542        // (4) A tool that returns a handled failure via `ToolReturn` shows the
1543        // model useful output while the outcome is a classified error.
1544        #[tokio::test]
1545        async fn handled_failure_delivers_model_output_and_error_outcome() {
1546            let hook = OutcomeHook::default();
1547            AgentBuilder::new(model_one_tool_then_text("lookup"))
1548                .tool(MockHandledFailureTool)
1549                .add_hook(hook.clone())
1550                .build()
1551                .runner("go")
1552                .max_turns(3)
1553                .run()
1554                .await
1555                .expect("a handled failure is not fatal");
1556
1557            assert_eq!(hook.outcomes(), vec!["error:not_found".to_string()]);
1558            assert_eq!(
1559                hook.results(),
1560                vec!["no record found for id 42; try a different id".to_string()],
1561                "the tool's model-visible output must survive alongside the error outcome"
1562            );
1563        }
1564
1565        // (7) `Flow::Skip` on the tool-call produces a structured `Skipped`
1566        // outcome that the result hook observes.
1567        #[tokio::test]
1568        async fn flow_skip_produces_skipped_outcome() {
1569            struct SkipHook;
1570            impl<M: CompletionModel> AgentHook<M> for SkipHook {
1571                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1572                    if let StepEvent::ToolCall { .. } = event {
1573                        Flow::skip("not executed (denied by policy); do not retry")
1574                    } else {
1575                        Flow::cont()
1576                    }
1577                }
1578            }
1579
1580            let observer = OutcomeHook::default();
1581            AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1582                .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1583                .add_hook(SkipHook)
1584                .add_hook(observer.clone())
1585                .build()
1586                .runner("go")
1587                .max_turns(3)
1588                .run()
1589                .await
1590                .expect("run should succeed after skipping the tool");
1591
1592            assert_eq!(observer.outcomes(), vec!["skipped".to_string()]);
1593            assert_eq!(
1594                observer.results(),
1595                vec!["not executed (denied by policy); do not retry".to_string()]
1596            );
1597        }
1598
1599        // A *tool-authored* denial (`ToolReturn::denied`) surfaces as a `Denied`
1600        // outcome — distinct from a hook `Flow::Skip`, which is `Skipped`. This
1601        // pins the documented `Skipped` vs `Denied` split: `Denied` comes only
1602        // from the tool, never from a hook skip.
1603        #[tokio::test]
1604        async fn tool_authored_denial_produces_denied_outcome() {
1605            let hook = OutcomeHook::default();
1606            AgentBuilder::new(model_one_tool_then_text("guarded"))
1607                .tool(MockDeniedTool)
1608                .add_hook(hook.clone())
1609                .build()
1610                .runner("go")
1611                .max_turns(3)
1612                .run()
1613                .await
1614                .expect("a tool-authored denial is not fatal");
1615
1616            assert_eq!(hook.outcomes(), vec!["denied".to_string()]);
1617            assert_eq!(
1618                hook.results(),
1619                vec!["access to this resource is not permitted".to_string()],
1620                "the model still receives the tool's denial message"
1621            );
1622        }
1623
1624        // A `RewriteArgs` hook followed by a `Skip` hook: the tool must not run,
1625        // the `ToolResult` reports the *rewritten* args (not the model's
1626        // original), and the outcome is `Skipped` — the rewrite (e.g. a
1627        // redaction) is not lost when a later hook short-circuits. Verified on
1628        // both the blocking and streaming surfaces.
1629        #[tokio::test]
1630        async fn rewrite_args_then_skip_reports_rewritten_args() {
1631            // Rewrites the tool args, replacing whatever the model emitted.
1632            struct RewriteHook;
1633            impl<M: CompletionModel> AgentHook<M> for RewriteHook {
1634                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1635                    if let StepEvent::ToolCall { .. } = event {
1636                        Flow::rewrite_args(json!({ "x": 41, "y": 1 }))
1637                    } else {
1638                        Flow::cont()
1639                    }
1640                }
1641            }
1642            // Skips *after* the rewrite (registered second).
1643            struct SkipHook;
1644            impl<M: CompletionModel> AgentHook<M> for SkipHook {
1645                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1646                    if let StepEvent::ToolCall { .. } = event {
1647                        Flow::skip("denied after rewrite")
1648                    } else {
1649                        Flow::cont()
1650                    }
1651                }
1652            }
1653            // Records the args + outcome seen on the `ToolResult` event.
1654            #[derive(Clone, Default)]
1655            struct ArgsProbe {
1656                args: Arc<Mutex<Option<String>>>,
1657                outcome: Arc<Mutex<Option<String>>>,
1658            }
1659            impl<M: CompletionModel> AgentHook<M> for ArgsProbe {
1660                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1661                    if let StepEvent::ToolResult { args, outcome, .. } = event {
1662                        *self.args.lock().expect("args") = Some(args.to_string());
1663                        *self.outcome.lock().expect("outcome") = Some(outcome_label(outcome));
1664                    }
1665                    Flow::cont()
1666                }
1667            }
1668
1669            async fn run_surface(streaming: bool) -> (String, String) {
1670                let probe = ArgsProbe::default();
1671                // The tool must never execute; `MockAddTool` would produce a
1672                // `Success` outcome with result "42" if it (wrongly) ran.
1673                if streaming {
1674                    let mut stream = AgentBuilder::new(stream_model_one_tool_then_text("add"))
1675                        .tool(MockAddTool)
1676                        .add_hook(RewriteHook)
1677                        .add_hook(SkipHook)
1678                        .add_hook(probe.clone())
1679                        .build()
1680                        .runner("go")
1681                        .max_turns(3)
1682                        .stream()
1683                        .await;
1684                    while let Some(item) = stream.next().await {
1685                        if let Err(err) = item {
1686                            panic!("stream item errored: {err}");
1687                        }
1688                    }
1689                } else {
1690                    AgentBuilder::new(model_one_tool_then_text("add"))
1691                        .tool(MockAddTool)
1692                        .add_hook(RewriteHook)
1693                        .add_hook(SkipHook)
1694                        .add_hook(probe.clone())
1695                        .build()
1696                        .runner("go")
1697                        .max_turns(3)
1698                        .run()
1699                        .await
1700                        .expect("run should succeed after skipping the tool");
1701                }
1702                let args = probe.args.lock().expect("args").clone().expect("args seen");
1703                let outcome = probe
1704                    .outcome
1705                    .lock()
1706                    .expect("outcome")
1707                    .clone()
1708                    .expect("outcome seen");
1709                (args, outcome)
1710            }
1711
1712            for streaming in [false, true] {
1713                let (args, outcome) = run_surface(streaming).await;
1714                assert_eq!(
1715                    outcome, "skipped",
1716                    "the skipped tool must produce a Skipped outcome (streaming={streaming})"
1717                );
1718                let parsed: serde_json::Value =
1719                    serde_json::from_str(&args).expect("ToolResult args are valid JSON");
1720                assert_eq!(
1721                    parsed,
1722                    json!({ "x": 41, "y": 1 }),
1723                    "the skipped ToolResult must report the rewritten args, not the model's \
1724                     original {{}} (streaming={streaming}); got {args}"
1725                );
1726            }
1727        }
1728
1729        // End-to-end nesting: a *nested* `HookStack` that rewrites args then skips
1730        // must still report the rewritten args on the skipped `ToolResult` — the
1731        // inner rewrite is not lost behind the inner skip when the stack is added
1732        // as a single composed hook. Guards the nested-composition fix.
1733        #[tokio::test]
1734        async fn nested_hook_stack_rewrite_then_skip_reports_rewritten_args() {
1735            struct RewriteHook;
1736            impl<M: CompletionModel> AgentHook<M> for RewriteHook {
1737                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1738                    if let StepEvent::ToolCall { .. } = event {
1739                        Flow::rewrite_args(json!({ "x": 41, "y": 1 }))
1740                    } else {
1741                        Flow::cont()
1742                    }
1743                }
1744            }
1745            struct SkipHook;
1746            impl<M: CompletionModel> AgentHook<M> for SkipHook {
1747                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1748                    if let StepEvent::ToolCall { .. } = event {
1749                        Flow::skip("denied after nested rewrite")
1750                    } else {
1751                        Flow::cont()
1752                    }
1753                }
1754            }
1755            #[derive(Clone, Default)]
1756            struct ArgsProbe {
1757                args: Arc<Mutex<Option<String>>>,
1758                outcome: Arc<Mutex<Option<String>>>,
1759            }
1760            impl<M: CompletionModel> AgentHook<M> for ArgsProbe {
1761                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1762                    if let StepEvent::ToolResult { args, outcome, .. } = event {
1763                        *self.args.lock().expect("args") = Some(args.to_string());
1764                        *self.outcome.lock().expect("outcome") = Some(outcome_label(outcome));
1765                    }
1766                    Flow::cont()
1767                }
1768            }
1769
1770            // The rewrite + skip live inside a *nested* stack added as one hook.
1771            fn nested_stack() -> HookStack<MockCompletionModel> {
1772                let mut nested = HookStack::<MockCompletionModel>::new();
1773                nested.push(RewriteHook);
1774                nested.push(SkipHook);
1775                nested
1776            }
1777
1778            // Verified on both surfaces: run_single_tool (shared) drives the same
1779            // nested resolution, so blocking and streaming must agree.
1780            for streaming in [false, true] {
1781                let probe = ArgsProbe::default();
1782                if streaming {
1783                    let mut stream = AgentBuilder::new(stream_model_one_tool_then_text("add"))
1784                        .tool(MockAddTool)
1785                        .add_hook(nested_stack())
1786                        .add_hook(probe.clone())
1787                        .build()
1788                        .runner("go")
1789                        .max_turns(3)
1790                        .stream()
1791                        .await;
1792                    while let Some(item) = stream.next().await {
1793                        if let Err(err) = item {
1794                            panic!("stream item errored: {err}");
1795                        }
1796                    }
1797                } else {
1798                    AgentBuilder::new(model_one_tool_then_text("add"))
1799                        .tool(MockAddTool)
1800                        .add_hook(nested_stack())
1801                        .add_hook(probe.clone())
1802                        .build()
1803                        .runner("go")
1804                        .max_turns(3)
1805                        .run()
1806                        .await
1807                        .expect("run should succeed after the nested stack skips the tool");
1808                }
1809
1810                assert_eq!(
1811                    probe.outcome.lock().expect("outcome").clone(),
1812                    Some("skipped".to_string()),
1813                    "streaming={streaming}"
1814                );
1815                let args = probe.args.lock().expect("args").clone().expect("args seen");
1816                let parsed: serde_json::Value =
1817                    serde_json::from_str(&args).expect("valid JSON args");
1818                assert_eq!(
1819                    parsed,
1820                    json!({ "x": 41, "y": 1 }),
1821                    "the nested stack's rewrite must survive its skip and reach the ToolResult \
1822                     (streaming={streaming}); got {args}"
1823                );
1824            }
1825        }
1826
1827        // (8) Invalid JSON arguments are classified as a structured `InvalidArgs`
1828        // failure rather than surfacing as an opaque string.
1829        #[tokio::test]
1830        async fn invalid_args_are_classified_as_invalid_args() {
1831            let hook = OutcomeHook::default();
1832            AgentBuilder::new(MockCompletionModel::from_turns([
1833                // `add` needs integers; a string is a hard parse failure.
1834                MockTurn::tool_call("tc1", "add", json!({ "x": "not-a-number", "y": 1 })),
1835                MockTurn::text("done"),
1836            ]))
1837            .tool(MockAddTool)
1838            .add_hook(hook.clone())
1839            .build()
1840            .runner("go")
1841            .max_turns(3)
1842            .run()
1843            .await
1844            .expect("an invalid-args failure is model-visible feedback, not fatal");
1845
1846            assert_eq!(hook.outcomes(), vec!["error:invalid_args".to_string()]);
1847        }
1848
1849        // Result extensions a tool attaches reach the hook but never appear in the
1850        // model-visible output.
1851        #[tokio::test]
1852        async fn success_extensions_reach_hook_but_not_model() {
1853            let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1854            let model_output: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1855
1856            struct ExtProbe {
1857                seen: Arc<Mutex<Option<String>>>,
1858                model_output: Arc<Mutex<Option<String>>>,
1859            }
1860            impl<M: CompletionModel> AgentHook<M> for ExtProbe {
1861                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1862                    if let StepEvent::ToolResult {
1863                        result, extensions, ..
1864                    } = event
1865                    {
1866                        *self.seen.lock().expect("seen") =
1867                            extensions.get::<MockRequestId>().map(|id| id.0.clone());
1868                        *self.model_output.lock().expect("model_output") = Some(result.to_string());
1869                    }
1870                    Flow::cont()
1871                }
1872            }
1873
1874            AgentBuilder::new(model_one_tool_then_text("with_meta"))
1875                .tool(MockMetadataTool)
1876                .add_hook(ExtProbe {
1877                    seen: seen.clone(),
1878                    model_output: model_output.clone(),
1879                })
1880                .build()
1881                .runner("go")
1882                .max_turns(3)
1883                .run()
1884                .await
1885                .expect("run should succeed");
1886
1887            assert_eq!(
1888                *seen.lock().expect("seen"),
1889                Some("req-7".to_string()),
1890                "the tool's result extension must reach the hook"
1891            );
1892            let output = model_output
1893                .lock()
1894                .expect("model_output")
1895                .clone()
1896                .expect("output");
1897            assert_eq!(output, "done");
1898            assert!(
1899                !output.contains("req-7"),
1900                "result extensions must never leak into the model-visible output"
1901            );
1902        }
1903
1904        // (6) A `RewriteResult` hook redacts the model-visible text, but a later
1905        // policy hook still sees the tool's *raw* structured outcome — a rewrite
1906        // changes only what the model sees, not the classification.
1907        #[tokio::test]
1908        async fn rewrite_result_does_not_mask_the_structured_outcome() {
1909            struct Redact;
1910            impl<M: CompletionModel> AgentHook<M> for Redact {
1911                async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1912                    if let StepEvent::ToolResult { .. } = event {
1913                        Flow::rewrite_result("[REDACTED]")
1914                    } else {
1915                        Flow::cont()
1916                    }
1917                }
1918            }
1919
1920            let observer = OutcomeHook::default();
1921            AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1922                .tool(MockFailingTool::new(ToolFailureKind::NotFound))
1923                // Observer AFTER the redactor: it still sees the true outcome, and
1924                // the chained (redacted) model-visible result.
1925                .add_hook(Redact)
1926                .add_hook(observer.clone())
1927                .build()
1928                .runner("go")
1929                .max_turns(3)
1930                .run()
1931                .await
1932                .expect("run should succeed");
1933
1934            assert_eq!(observer.outcomes(), vec!["error:not_found".to_string()]);
1935            assert_eq!(observer.results(), vec!["[REDACTED]".to_string()]);
1936        }
1937
1938        // (9) The blocking and streaming surfaces observe identical structured
1939        // outcomes for the same scenario.
1940        #[tokio::test]
1941        async fn streaming_and_blocking_outcomes_match() {
1942            let blocking = OutcomeHook::default();
1943            AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1944                .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1945                .add_hook(blocking.clone())
1946                .build()
1947                .runner("go")
1948                .max_turns(3)
1949                .run()
1950                .await
1951                .expect("blocking run should succeed");
1952
1953            let streaming = OutcomeHook::default();
1954            let mut stream = AgentBuilder::new(stream_model_one_tool_then_text("flaky_tool"))
1955                .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1956                .add_hook(streaming.clone())
1957                .build()
1958                .runner("go")
1959                .max_turns(3)
1960                .stream()
1961                .await;
1962            while let Some(item) = stream.next().await {
1963                if let Err(err) = item {
1964                    panic!("stream item errored: {err}");
1965                }
1966            }
1967
1968            assert_eq!(blocking.outcomes(), vec!["error:timeout".to_string()]);
1969            assert_eq!(blocking.outcomes(), streaming.outcomes());
1970            assert_eq!(blocking.results(), streaming.results());
1971        }
1972
1973        // (10) With two tools in one turn at `concurrency > 1`, both structured
1974        // outcomes are observed and the persisted tool results keep call order.
1975        #[tokio::test]
1976        async fn concurrent_tools_preserve_order_and_both_outcomes() {
1977            use crate::message::{AssistantContent, ToolCall, ToolFunction, UserContent};
1978
1979            let turn = MockTurn::from_contents([
1980                AssistantContent::ToolCall(ToolCall::new(
1981                    "tc_add".to_string(),
1982                    ToolFunction::new("add".to_string(), json!({ "x": 2, "y": 3 })),
1983                )),
1984                AssistantContent::ToolCall(ToolCall::new(
1985                    "tc_flaky".to_string(),
1986                    ToolFunction::new("flaky_tool".to_string(), json!({})),
1987                )),
1988            ])
1989            .expect("two tool calls");
1990
1991            let observer = OutcomeHook::default();
1992            let response = AgentBuilder::new(MockCompletionModel::from_turns([
1993                turn,
1994                MockTurn::text("done"),
1995            ]))
1996            .tool(MockAddTool)
1997            .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1998            .add_hook(observer.clone())
1999            .build()
2000            .runner("go")
2001            .max_turns(3)
2002            .tool_concurrency(2)
2003            .run()
2004            .await
2005            .expect("run should succeed");
2006
2007            // Hook order may interleave under concurrency, so compare as a set.
2008            let mut outcomes = observer.outcomes();
2009            outcomes.sort();
2010            assert_eq!(
2011                outcomes,
2012                vec!["error:timeout".to_string(), "success".to_string()]
2013            );
2014
2015            // The persisted tool results must keep tool-call order regardless of
2016            // completion timing: `add` (tc_add) before `flaky_tool` (tc_flaky).
2017            let messages = response.messages.expect("messages");
2018            let tool_result_ids: Vec<String> = messages
2019                .iter()
2020                .flat_map(|message| match message {
2021                    crate::completion::Message::User { content } => content
2022                        .iter()
2023                        .filter_map(|c| match c {
2024                            UserContent::ToolResult(result) => Some(result.id.clone()),
2025                            _ => None,
2026                        })
2027                        .collect::<Vec<_>>(),
2028                    _ => Vec::new(),
2029                })
2030                .collect();
2031            assert_eq!(
2032                tool_result_ids,
2033                vec!["tc_add".to_string(), "tc_flaky".to_string()],
2034                "tool results must be persisted in call order"
2035            );
2036        }
2037    }
2038
2039    /// Safety net for the streaming/non-streaming unification: pins the blocking
2040    /// driver's span topology (span name, `invoke_agent` creation, the
2041    /// `follows_from` chain, and `created_agent_span`-gated run-level usage) so a
2042    /// later refactor onto a shared engine cannot silently drift it. The
2043    /// streaming side is already pinned by `assert_stream_usage_recorded_on_chat_spans`.
2044    mod span_safety_net {
2045        use std::collections::{HashMap, HashSet};
2046        use std::sync::{Arc, Mutex};
2047
2048        use tracing::Instrument;
2049        use tracing::field::{Field, Visit};
2050        use tracing::span::{Attributes, Record};
2051        use tracing::{Id, Subscriber};
2052        use tracing_subscriber::layer::{Context, SubscriberExt};
2053        use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
2054
2055        use crate::agent::AgentBuilder;
2056        use crate::completion::Usage;
2057        use crate::test_utils::{MockAddTool, MockCompletionModel, MockTurn};
2058
2059        #[derive(Clone)]
2060        struct CapturedSpan {
2061            id: u64,
2062            name: String,
2063            field_names: HashSet<String>,
2064            u64_fields: HashMap<String, u64>,
2065        }
2066
2067        #[derive(Clone, Default)]
2068        struct Captured {
2069            spans: Arc<Mutex<Vec<CapturedSpan>>>,
2070            /// `(span, follows_from)` pairs recorded via `Span::follows_from`.
2071            follows: Arc<Mutex<Vec<(u64, u64)>>>,
2072        }
2073
2074        impl Captured {
2075            fn insert(&self, id: &Id, name: &str) {
2076                self.spans.lock().expect("spans").push(CapturedSpan {
2077                    id: id.into_u64(),
2078                    name: name.to_string(),
2079                    field_names: HashSet::new(),
2080                    u64_fields: HashMap::new(),
2081                });
2082            }
2083
2084            fn record(&self, id: &Id, names: HashSet<String>, u64s: HashMap<String, u64>) {
2085                let id = id.into_u64();
2086                if let Ok(mut spans) = self.spans.lock()
2087                    && let Some(span) = spans.iter_mut().find(|s| s.id == id)
2088                {
2089                    span.field_names.extend(names);
2090                    span.u64_fields.extend(u64s);
2091                }
2092            }
2093
2094            fn follows_from(&self, span: &Id, follows: &Id) {
2095                self.follows
2096                    .lock()
2097                    .expect("follows")
2098                    .push((span.into_u64(), follows.into_u64()));
2099            }
2100
2101            fn clear(&self) {
2102                self.spans.lock().expect("spans").clear();
2103                self.follows.lock().expect("follows").clear();
2104            }
2105
2106            fn snapshot(&self) -> Vec<CapturedSpan> {
2107                self.spans.lock().expect("spans").clone()
2108            }
2109
2110            fn follows_edges(&self) -> Vec<(u64, u64)> {
2111                self.follows.lock().expect("follows").clone()
2112            }
2113        }
2114
2115        struct CaptureLayer {
2116            captured: Captured,
2117        }
2118
2119        impl<S> Layer<S> for CaptureLayer
2120        where
2121            S: Subscriber + for<'l> LookupSpan<'l>,
2122        {
2123            fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, _ctx: Context<'_, S>) {
2124                self.captured.insert(id, attrs.metadata().name());
2125            }
2126
2127            fn on_record(&self, span: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
2128                let mut visitor = FieldVisitor::default();
2129                values.record(&mut visitor);
2130                self.captured.record(span, visitor.names, visitor.u64s);
2131            }
2132
2133            fn on_follows_from(&self, span: &Id, follows: &Id, _ctx: Context<'_, S>) {
2134                self.captured.follows_from(span, follows);
2135            }
2136        }
2137
2138        #[derive(Default)]
2139        struct FieldVisitor {
2140            names: HashSet<String>,
2141            u64s: HashMap<String, u64>,
2142        }
2143
2144        impl Visit for FieldVisitor {
2145            fn record_u64(&mut self, field: &Field, value: u64) {
2146                self.names.insert(field.name().to_string());
2147                self.u64s.insert(field.name().to_string(), value);
2148            }
2149
2150            fn record_str(&mut self, field: &Field, _value: &str) {
2151                self.names.insert(field.name().to_string());
2152            }
2153
2154            fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
2155                self.names.insert(field.name().to_string());
2156            }
2157        }
2158
2159        fn usage(input: u64, output: u64) -> Usage {
2160            Usage {
2161                input_tokens: input,
2162                output_tokens: output,
2163                ..Usage::new()
2164            }
2165        }
2166
2167        /// Two-turn tool scenario: the blocking driver emits chat -> execute_tool
2168        /// -> chat, exercising the `follows_from` chain.
2169        fn tool_then_text_model() -> MockCompletionModel {
2170            MockCompletionModel::from_turns([
2171                MockTurn::tool_call("tc1", "add", serde_json::json!({"x": 2, "y": 3}))
2172                    .with_usage(usage(7, 11)),
2173                MockTurn::text("the answer is 5").with_usage(usage(13, 17)),
2174            ])
2175        }
2176
2177        /// Register the blocking driver's span callsites against the scoped
2178        /// subscriber before asserting, mirroring the streaming usage test's
2179        /// interest-cache warm-up (a foreign thread without our subscriber can
2180        /// otherwise cache `Interest::never` for these callsites).
2181        async fn warm_blocking_callsites() {
2182            let agent = AgentBuilder::new(tool_then_text_model())
2183                .tool(MockAddTool)
2184                .build();
2185            let _ = agent.runner("add 2 and 3").max_turns(3).run().await;
2186        }
2187
2188        #[tokio::test]
2189        async fn run_records_usage_and_chains_chat_spans_on_a_created_agent_span() {
2190            let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2191            let captured = Captured::default();
2192            let subscriber = Registry::default().with(CaptureLayer {
2193                captured: captured.clone(),
2194            });
2195            let _default = tracing::subscriber::set_default(subscriber);
2196
2197            warm_blocking_callsites().await;
2198            tracing::callsite::rebuild_interest_cache();
2199            captured.clear();
2200
2201            let agent = AgentBuilder::new(tool_then_text_model())
2202                .tool(MockAddTool)
2203                .build();
2204            let response = agent
2205                .runner("add 2 and 3")
2206                .max_turns(3)
2207                .run()
2208                .await
2209                .expect("blocking run should succeed");
2210            assert_eq!(response.output, "the answer is 5");
2211
2212            let spans = captured.snapshot();
2213
2214            // The blocking chat span is named "chat" (NOT "chat_streaming").
2215            let chat_spans: Vec<&CapturedSpan> =
2216                spans.iter().filter(|s| s.name == "chat").collect();
2217            assert_eq!(chat_spans.len(), 2, "two model turns -> two chat spans");
2218            assert!(
2219                spans.iter().all(|s| s.name != "chat_streaming"),
2220                "blocking driver must not emit chat_streaming spans"
2221            );
2222
2223            // A run with no ambient span creates its own invoke_agent span...
2224            let agent_span = spans
2225                .iter()
2226                .find(|s| s.name == "invoke_agent")
2227                .expect("blocking run should create an invoke_agent span");
2228
2229            // ...and records aggregate usage + completion onto it (created_agent_span).
2230            assert_eq!(
2231                agent_span.u64_fields.get("gen_ai.usage.input_tokens"),
2232                Some(&(7 + 13)),
2233            );
2234            assert_eq!(
2235                agent_span.u64_fields.get("gen_ai.usage.output_tokens"),
2236                Some(&(11 + 17)),
2237            );
2238            assert!(
2239                agent_span.field_names.contains("gen_ai.completion"),
2240                "the created agent span records the final completion text"
2241            );
2242
2243            // The blocking driver links chat/tool spans into a linear
2244            // follows_from chain (chat#1 -> execute_tool -> chat#2); the
2245            // streaming driver does not, so this is a blocking-only invariant the
2246            // unification must keep.
2247            let tool_span = spans
2248                .iter()
2249                .find(|s| s.name == "execute_tool")
2250                .expect("tool turn should emit an execute_tool span");
2251            let edges = captured.follows_edges();
2252            assert!(
2253                edges.contains(&(tool_span.id, chat_spans[0].id)),
2254                "execute_tool should follow_from the first chat span; edges={edges:?}"
2255            );
2256            assert!(
2257                edges.contains(&(chat_spans[1].id, tool_span.id)),
2258                "the second chat span should follow_from execute_tool; edges={edges:?}"
2259            );
2260        }
2261
2262        #[tokio::test]
2263        async fn run_does_not_record_usage_onto_a_caller_supplied_outer_span() {
2264            let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2265            let captured = Captured::default();
2266            let subscriber = Registry::default().with(CaptureLayer {
2267                captured: captured.clone(),
2268            });
2269            let _default = tracing::subscriber::set_default(subscriber);
2270
2271            warm_blocking_callsites().await;
2272            tracing::callsite::rebuild_interest_cache();
2273            captured.clear();
2274
2275            // Declare the fields the guard protects so a regression (recording
2276            // onto a caller span) is actually observable rather than a silent
2277            // no-op on an undeclared field.
2278            let outer = tracing::info_span!(
2279                "outer",
2280                gen_ai.completion = tracing::field::Empty,
2281                gen_ai.usage.input_tokens = tracing::field::Empty,
2282                gen_ai.usage.output_tokens = tracing::field::Empty,
2283            );
2284            async {
2285                let agent = AgentBuilder::new(tool_then_text_model())
2286                    .tool(MockAddTool)
2287                    .build();
2288                agent
2289                    .runner("add 2 and 3")
2290                    .max_turns(3)
2291                    .run()
2292                    .await
2293                    .expect("blocking run should succeed");
2294            }
2295            .instrument(outer)
2296            .await;
2297
2298            let spans = captured.snapshot();
2299            // Under an ambient span the driver adopts it; no invoke_agent is created.
2300            assert!(
2301                spans.iter().all(|s| s.name != "invoke_agent"),
2302                "an ambient outer span should be adopted, not wrapped in invoke_agent"
2303            );
2304            let outer_span = spans
2305                .iter()
2306                .find(|s| s.name == "outer")
2307                .expect("outer span should be captured");
2308            assert!(
2309                outer_span
2310                    .field_names
2311                    .iter()
2312                    .all(|name| !name.starts_with("gen_ai.usage.")),
2313                "run-level usage must not be recorded onto a caller-supplied outer span"
2314            );
2315            assert!(
2316                !outer_span.field_names.contains("gen_ai.completion"),
2317                "run-level completion must not be recorded onto a caller-supplied outer span"
2318            );
2319        }
2320
2321        // --- Tool-result redaction: raw output must not leak to the span ---
2322
2323        /// A tool that returns a secret; a redaction hook replaces it before the
2324        /// model — and the trace — sees it.
2325        struct SecretTool;
2326        impl crate::tool::Tool for SecretTool {
2327            const NAME: &'static str = "leak";
2328            type Error = crate::test_utils::MockToolError;
2329            type Args = serde_json::Value;
2330            type Output = String;
2331            fn description(&self) -> String {
2332                "returns a secret".to_string()
2333            }
2334
2335            fn parameters(&self) -> serde_json::Value {
2336                serde_json::json!({ "type": "object", "properties": {} })
2337            }
2338            async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
2339                Ok("SUPER_SECRET_TOKEN_42".to_string())
2340            }
2341        }
2342
2343        /// Redacts every tool result before the model sees it.
2344        struct RedactResultHook;
2345        impl<M: crate::completion::CompletionModel> crate::agent::AgentHook<M> for RedactResultHook {
2346            async fn on_event(
2347                &self,
2348                _ctx: &crate::agent::HookContext,
2349                event: crate::agent::StepEvent<'_, M>,
2350            ) -> crate::agent::Flow {
2351                if let crate::agent::StepEvent::ToolResult { .. } = event {
2352                    crate::agent::Flow::rewrite_result("[REDACTED]")
2353                } else {
2354                    crate::agent::Flow::cont()
2355                }
2356            }
2357        }
2358
2359        /// Captures every value recorded into the `gen_ai.tool.call.result` span
2360        /// field, so a test can assert the raw secret never reaches the trace.
2361        #[derive(Default)]
2362        struct ResultValueVisitor {
2363            values: Vec<String>,
2364        }
2365        impl Visit for ResultValueVisitor {
2366            fn record_str(&mut self, field: &Field, value: &str) {
2367                if field.name() == "gen_ai.tool.call.result" {
2368                    self.values.push(value.to_string());
2369                }
2370            }
2371            fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2372                if field.name() == "gen_ai.tool.call.result" {
2373                    self.values.push(format!("{value:?}"));
2374                }
2375            }
2376        }
2377
2378        struct ResultValueLayer {
2379            values: Arc<Mutex<Vec<String>>>,
2380        }
2381        impl<S> Layer<S> for ResultValueLayer
2382        where
2383            S: Subscriber + for<'l> LookupSpan<'l>,
2384        {
2385            fn on_record(&self, _span: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
2386                let mut visitor = ResultValueVisitor::default();
2387                values.record(&mut visitor);
2388                if !visitor.values.is_empty() {
2389                    self.values.lock().expect("values").extend(visitor.values);
2390                }
2391            }
2392        }
2393
2394        /// A `ToolResult` hook that redacts the tool's output must prevent the raw
2395        /// secret from ever reaching the `gen_ai.tool.call.result` span field: the
2396        /// result is recorded only AFTER the hook runs, so only the redacted
2397        /// replacement is traced.
2398        #[tokio::test]
2399        async fn tool_result_redaction_does_not_leak_raw_output_to_the_span() {
2400            let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2401            let values: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2402            let subscriber = Registry::default().with(ResultValueLayer {
2403                values: values.clone(),
2404            });
2405            let _default = tracing::subscriber::set_default(subscriber);
2406
2407            // Warm the `execute_tool` result callsite under this subscriber, then
2408            // reset — mirroring the usage tests' interest-cache warm-up.
2409            warm_blocking_callsites().await;
2410            tracing::callsite::rebuild_interest_cache();
2411            values.lock().expect("values").clear();
2412
2413            let model = MockCompletionModel::from_turns([
2414                MockTurn::tool_call("tc1", "leak", serde_json::json!({})),
2415                MockTurn::text("ok"),
2416            ]);
2417            let response = AgentBuilder::new(model)
2418                .tool(SecretTool)
2419                .add_hook(RedactResultHook)
2420                .build()
2421                .runner("go")
2422                .max_turns(3)
2423                .run()
2424                .await
2425                .expect("run should succeed");
2426            assert_eq!(response.output, "ok");
2427
2428            let captured = values.lock().expect("values").clone();
2429            assert!(
2430                !captured.iter().any(|v| v.contains("SUPER_SECRET_TOKEN_42")),
2431                "the raw tool output must never be recorded on the span; captured: {captured:?}"
2432            );
2433            assert!(
2434                captured.iter().any(|v| v.contains("[REDACTED]")),
2435                "only the redacted replacement is recorded on the span; captured: {captured:?}"
2436            );
2437        }
2438    }
2439
2440    fn tool_call_content(id: &str, args: serde_json::Value) -> AssistantContent {
2441        AssistantContent::ToolCall(ToolCall::new(
2442            id.to_string(),
2443            ToolFunction::new("add".to_string(), args),
2444        ))
2445    }
2446
2447    /// Whether any tool result in `messages` carries `expected` as verbatim text.
2448    /// Used to pin a skip reason's actual value (a reason dropped or altered on
2449    /// both drivers would still satisfy a blocking == streaming equality check).
2450    fn tool_result_text_in_history(messages: &[Message], expected: &str) -> bool {
2451        messages.iter().any(|message| {
2452            matches!(
2453                message,
2454                Message::User { content }
2455                    if content.iter().any(|item| matches!(
2456                        item,
2457                        UserContent::ToolResult(result)
2458                            if result.content.iter().any(|c| matches!(
2459                                c,
2460                                crate::message::ToolResultContent::Text(text)
2461                                    if text.text == expected
2462                            ))
2463                    ))
2464            )
2465        })
2466    }
2467
2468    /// Even with `run()` executing tools concurrently, the tool-result order —
2469    /// and so the whole message history — matches the sequential streaming
2470    /// driver. (`run()` runs tools with `buffer_unordered` but writes each result
2471    /// into its original call-index slot, so results still land in call order.)
2472    #[tokio::test]
2473    async fn run_and_stream_same_message_history_for_parallel_tool_calls() {
2474        let blocking_model = MockCompletionModel::from_turns([
2475            MockTurn::from_contents([
2476                tool_call_content("tc1", json!({"x": 2, "y": 3})),
2477                tool_call_content("tc2", json!({"x": 10, "y": 20})),
2478            ])
2479            .expect("two tool calls is a valid turn"),
2480            MockTurn::text("done"),
2481        ]);
2482        let blocking = AgentBuilder::new(blocking_model)
2483            .tool(MockAddTool)
2484            .build()
2485            .runner("add two pairs")
2486            .max_turns(3)
2487            .tool_concurrency(4)
2488            .run()
2489            .await
2490            .expect("blocking run should succeed");
2491
2492        let streaming_model = MockCompletionModel::from_stream_turns([
2493            vec![
2494                MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
2495                MockStreamEvent::tool_call("tc2", "add", json!({"x": 10, "y": 20})),
2496                MockStreamEvent::final_response_with_total_tokens(0),
2497            ],
2498            vec![
2499                MockStreamEvent::text("done"),
2500                MockStreamEvent::final_response_with_total_tokens(0),
2501            ],
2502        ]);
2503        let mut stream = AgentBuilder::new(streaming_model)
2504            .tool(MockAddTool)
2505            .build()
2506            .runner("add two pairs")
2507            .max_turns(3)
2508            .stream()
2509            .await;
2510        let mut final_response = None;
2511        while let Some(item) = stream.next().await {
2512            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
2513                item.map_err(|err| panic!("stream item errored: {err}"))
2514            {
2515                final_response = Some(resp);
2516            }
2517        }
2518        let final_response = final_response.expect("stream should yield a final response");
2519
2520        let blocking_messages = blocking.messages.expect("blocking messages");
2521        let streaming_messages = final_response
2522            .messages()
2523            .expect("streaming history")
2524            .to_vec();
2525        assert_eq!(
2526            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
2527            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
2528        );
2529    }
2530
2531    /// A tool whose first-*called* invocation completes *after* the second, so
2532    /// `buffer_unordered` yields the results in completion order — yet the
2533    /// persisted history stays in call order because each result is written into
2534    /// its original call-index slot. The first call (in poll/call order) waits on
2535    /// a gate the second call releases.
2536    #[derive(Clone)]
2537    struct OutOfOrderTool {
2538        gate: Arc<tokio::sync::Notify>,
2539        order: Arc<AtomicU32>,
2540    }
2541
2542    impl Tool for OutOfOrderTool {
2543        const NAME: &'static str = "add";
2544        type Error = MockToolError;
2545        type Args = MockOperationArgs;
2546        type Output = i32;
2547
2548        fn description(&self) -> String {
2549            MockAddTool.description()
2550        }
2551
2552        fn parameters(&self) -> serde_json::Value {
2553            MockAddTool.parameters()
2554        }
2555
2556        async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
2557            let nth = self.order.fetch_add(1, SeqCst);
2558            if nth == 0 {
2559                // First call: cannot finish until a later call releases us.
2560                self.gate.notified().await;
2561            } else {
2562                // Later call: finishes immediately and releases the first.
2563                self.gate.notify_one();
2564            }
2565            Ok(nth as i32)
2566        }
2567    }
2568
2569    /// `run()` must persist tool results in tool-call (emission) order even when
2570    /// tools complete out of order under concurrency — it runs them with
2571    /// `buffer_unordered` but reindexes each result into its original call-index
2572    /// slot. (This is what keeps its message history identical to the sequential
2573    /// streaming driver.)
2574    #[tokio::test]
2575    async fn run_preserves_tool_call_order_under_out_of_order_completion() {
2576        let model = MockCompletionModel::from_turns([
2577            MockTurn::from_contents([
2578                tool_call_content("tc1", json!({"x": 1, "y": 0})),
2579                tool_call_content("tc2", json!({"x": 2, "y": 0})),
2580            ])
2581            .expect("two tool calls is a valid turn"),
2582            MockTurn::text("done"),
2583        ]);
2584        let response = AgentBuilder::new(model)
2585            .tool(OutOfOrderTool {
2586                gate: Arc::new(tokio::sync::Notify::new()),
2587                order: Arc::new(AtomicU32::new(0)),
2588            })
2589            .build()
2590            .runner("go")
2591            .max_turns(3)
2592            .tool_concurrency(4)
2593            .run()
2594            .await
2595            .expect("run should succeed");
2596
2597        let messages = response.messages.expect("messages");
2598        let result_ids: Vec<String> = messages
2599            .iter()
2600            .flat_map(|message| match message {
2601                Message::User { content } => content
2602                    .iter()
2603                    .filter_map(|item| match item {
2604                        UserContent::ToolResult(result) => Some(result.id.clone()),
2605                        _ => None,
2606                    })
2607                    .collect::<Vec<_>>(),
2608                _ => Vec::new(),
2609            })
2610            .collect();
2611        // Call order (tc1 then tc2), even though tc2 finished first.
2612        assert_eq!(result_ids, vec!["tc1".to_string(), "tc2".to_string()]);
2613    }
2614
2615    /// Drive a stream to completion, panicking on any stream error, and return
2616    /// its final response.
2617    async fn drive_to_final_response<R: Send + 'static>(
2618        mut stream: crate::agent::prompt_request::streaming::StreamingResult<R>,
2619    ) -> crate::agent::prompt_request::PromptResponse {
2620        let mut final_response = None;
2621        while let Some(item) = stream.next().await {
2622            if let MultiTurnStreamItem::FinalResponse(resp) =
2623                item.unwrap_or_else(|err| panic!("stream item errored: {err}"))
2624            {
2625                final_response = Some(resp);
2626            }
2627        }
2628        final_response.expect("stream should yield a final response")
2629    }
2630
2631    /// Tool-result ids, in history order, across a run's message history.
2632    fn tool_result_ids(messages: &[Message]) -> Vec<String> {
2633        messages
2634            .iter()
2635            .flat_map(|message| match message {
2636                Message::User { content } => content
2637                    .iter()
2638                    .filter_map(|item| match item {
2639                        UserContent::ToolResult(result) => Some(result.id.clone()),
2640                        _ => None,
2641                    })
2642                    .collect::<Vec<_>>(),
2643                _ => Vec::new(),
2644            })
2645            .collect()
2646    }
2647
2648    /// The streaming driver under `tool_concurrency > 1` produces the **same
2649    /// message history** as the blocking driver: streamed results are surfaced in
2650    /// call order after the batch settles, and persisted results stay in tool-call
2651    /// order, so concurrency never reorders the final history.
2652    #[tokio::test]
2653    async fn stream_and_run_same_message_history_for_parallel_tool_calls_under_concurrency() {
2654        let blocking_model = MockCompletionModel::from_turns([
2655            MockTurn::from_contents([
2656                tool_call_content("tc1", json!({"x": 2, "y": 3})),
2657                tool_call_content("tc2", json!({"x": 10, "y": 20})),
2658            ])
2659            .expect("two tool calls is a valid turn"),
2660            MockTurn::text("done"),
2661        ]);
2662        let blocking = AgentBuilder::new(blocking_model)
2663            .tool(MockAddTool)
2664            .build()
2665            .runner("add two pairs")
2666            .max_turns(3)
2667            .tool_concurrency(4)
2668            .run()
2669            .await
2670            .expect("blocking run should succeed");
2671
2672        let streaming_model = MockCompletionModel::from_stream_turns([
2673            vec![
2674                MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
2675                MockStreamEvent::tool_call("tc2", "add", json!({"x": 10, "y": 20})),
2676                MockStreamEvent::final_response_with_total_tokens(0),
2677            ],
2678            vec![
2679                MockStreamEvent::text("done"),
2680                MockStreamEvent::final_response_with_total_tokens(0),
2681            ],
2682        ]);
2683        let stream = AgentBuilder::new(streaming_model)
2684            .tool(MockAddTool)
2685            .build()
2686            .runner("add two pairs")
2687            .max_turns(3)
2688            .tool_concurrency(4)
2689            .stream()
2690            .await;
2691        let final_response = drive_to_final_response(stream).await;
2692
2693        let blocking_messages = blocking.messages.expect("blocking messages");
2694        let streaming_messages = final_response
2695            .messages()
2696            .expect("streaming history")
2697            .to_vec();
2698        assert_eq!(
2699            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
2700            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
2701        );
2702    }
2703
2704    /// The streaming driver under concurrency persists tool results in **call
2705    /// order** even when tools complete out of order. `OutOfOrderTool`'s
2706    /// first-called invocation only finishes once the second runs, so this also
2707    /// proves the tools run concurrently: sequential execution would deadlock on
2708    /// the first call.
2709    #[tokio::test]
2710    async fn stream_preserves_history_order_under_out_of_order_completion() {
2711        let model = MockCompletionModel::from_stream_turns([
2712            vec![
2713                MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 0})),
2714                MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 0})),
2715                MockStreamEvent::final_response_with_total_tokens(0),
2716            ],
2717            vec![
2718                MockStreamEvent::text("done"),
2719                MockStreamEvent::final_response_with_total_tokens(0),
2720            ],
2721        ]);
2722        let stream = AgentBuilder::new(model)
2723            .tool(OutOfOrderTool {
2724                gate: Arc::new(tokio::sync::Notify::new()),
2725                order: Arc::new(AtomicU32::new(0)),
2726            })
2727            .build()
2728            .runner("go")
2729            .max_turns(3)
2730            .tool_concurrency(4)
2731            .stream()
2732            .await;
2733        // Timeout so a regression to sequential execution fails cleanly instead
2734        // of hanging (the first call only completes once the second runs).
2735        let final_response = tokio::time::timeout(
2736            std::time::Duration::from_secs(5),
2737            drive_to_final_response(stream),
2738        )
2739        .await
2740        .expect("streamed tools must run concurrently, not deadlock on the first call");
2741
2742        let messages = final_response.messages().expect("history").to_vec();
2743        // History stays in call order (tc1 then tc2), even though tc2 finished first.
2744        assert_eq!(
2745            tool_result_ids(&messages),
2746            vec!["tc1".to_string(), "tc2".to_string()]
2747        );
2748    }
2749
2750    /// Under concurrency the streaming driver surfaces tool results **atomically
2751    /// after the whole batch settles**, in **call order** — not as each tool
2752    /// completes. The second call completes first (via the gate), yet its result
2753    /// is still surfaced second, matching persisted history order.
2754    #[tokio::test]
2755    async fn stream_emits_tool_results_in_call_order_after_batch_settles_under_concurrency() {
2756        let model = MockCompletionModel::from_stream_turns([
2757            vec![
2758                MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 0})),
2759                MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 0})),
2760                MockStreamEvent::final_response_with_total_tokens(0),
2761            ],
2762            vec![
2763                MockStreamEvent::text("done"),
2764                MockStreamEvent::final_response_with_total_tokens(0),
2765            ],
2766        ]);
2767        let mut stream = AgentBuilder::new(model)
2768            .tool(OutOfOrderTool {
2769                gate: Arc::new(tokio::sync::Notify::new()),
2770                order: Arc::new(AtomicU32::new(0)),
2771            })
2772            .build()
2773            .runner("go")
2774            .max_turns(3)
2775            .tool_concurrency(4)
2776            .stream()
2777            .await;
2778
2779        let mut streamed_result_ids = Vec::new();
2780        let mut final_response = None;
2781        tokio::time::timeout(std::time::Duration::from_secs(5), async {
2782            while let Some(item) = stream.next().await {
2783                match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
2784                    MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2785                        tool_result,
2786                        ..
2787                    }) => streamed_result_ids.push(tool_result.id),
2788                    MultiTurnStreamItem::FinalResponse(resp) => final_response = Some(resp),
2789                    _ => {}
2790                }
2791            }
2792        })
2793        .await
2794        .expect("streamed tools must run concurrently, not deadlock on the first call");
2795
2796        // Call order, even though tc2 completed first — results are surfaced only
2797        // after the whole batch settles.
2798        assert_eq!(
2799            streamed_result_ids,
2800            vec!["tc1".to_string(), "tc2".to_string()]
2801        );
2802        let final_response = final_response.expect("stream should yield a final response");
2803        assert_eq!(
2804            tool_result_ids(final_response.messages().expect("history")),
2805            vec!["tc1".to_string(), "tc2".to_string()]
2806        );
2807    }
2808
2809    /// Two barrier-synchronized tools in one streamed turn finish only if they
2810    /// run concurrently — each waits at the barrier for the other. At
2811    /// `tool_concurrency(2)` the streamed turn completes; sequential execution
2812    /// would block on the first call forever, so the timeout asserts genuine
2813    /// concurrency on the streaming path.
2814    #[tokio::test]
2815    async fn stream_executes_tools_concurrently_under_concurrency() {
2816        let barrier = Arc::new(tokio::sync::Barrier::new(2));
2817        let model = MockCompletionModel::from_stream_turns([
2818            vec![
2819                MockStreamEvent::tool_call("b1", "barrier_tool", json!({})),
2820                MockStreamEvent::tool_call("b2", "barrier_tool", json!({})),
2821                MockStreamEvent::final_response_with_total_tokens(0),
2822            ],
2823            vec![
2824                MockStreamEvent::text("done"),
2825                MockStreamEvent::final_response_with_total_tokens(0),
2826            ],
2827        ]);
2828        let stream = AgentBuilder::new(model)
2829            .tool(MockBarrierTool::new(barrier))
2830            .build()
2831            .runner("hit the barrier twice")
2832            .max_turns(3)
2833            .tool_concurrency(2)
2834            .stream()
2835            .await;
2836
2837        tokio::time::timeout(
2838            std::time::Duration::from_secs(5),
2839            drive_to_final_response(stream),
2840        )
2841        .await
2842        .expect("streamed tools must run concurrently, not deadlock at the barrier");
2843    }
2844
2845    /// The stream-item taxonomy and ordering: the driver emits *all* of a turn's
2846    /// **model** tool-call items ([`StreamedAssistantContent::ToolCall`], one per
2847    /// call the model made) first, then — after the whole tool batch settles —
2848    /// the per-tool **execution** items (`ToolExecutionStart` then the
2849    /// `ToolResult`) in call order. This holds identically at every concurrency
2850    /// (the batch is atomic on both the sequential and concurrent paths).
2851    #[tokio::test]
2852    async fn stream_emits_model_tool_calls_then_atomic_execution_items() {
2853        async fn markers(concurrency: usize) -> Vec<&'static str> {
2854            let model = MockCompletionModel::from_stream_turns([
2855                vec![
2856                    MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
2857                    MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
2858                    MockStreamEvent::final_response_with_total_tokens(0),
2859                ],
2860                vec![
2861                    MockStreamEvent::text("done"),
2862                    MockStreamEvent::final_response_with_total_tokens(0),
2863                ],
2864            ]);
2865            let mut stream = AgentBuilder::new(model)
2866                .tool(MockAddTool)
2867                .build()
2868                .runner("add two pairs")
2869                .max_turns(3)
2870                .tool_concurrency(concurrency)
2871                .stream()
2872                .await;
2873            let mut markers = Vec::new();
2874            while let Some(item) = stream.next().await {
2875                match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
2876                    MultiTurnStreamItem::StreamAssistantItem(
2877                        StreamedAssistantContent::ToolCall { .. },
2878                    ) => markers.push("model-call"),
2879                    MultiTurnStreamItem::ToolExecutionStart { .. } => markers.push("exec-start"),
2880                    MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2881                        ..
2882                    }) => markers.push("result"),
2883                    _ => {}
2884                }
2885            }
2886            markers
2887        }
2888
2889        // Both surfaces: all model tool calls first, then per-tool (start, result)
2890        // in call order, surfaced atomically after the batch settles.
2891        let expected = vec![
2892            "model-call",
2893            "model-call",
2894            "exec-start",
2895            "result",
2896            "exec-start",
2897            "result",
2898        ];
2899        assert_eq!(markers(1).await, expected);
2900        assert_eq!(markers(4).await, expected);
2901    }
2902
2903    /// Terminates from the `x == 1` tool's result, but only *after* the slow
2904    /// `x == 2` sibling has signalled it started executing — so that sibling is
2905    /// genuinely in flight when the terminate fires (not merely not-yet-started).
2906    struct TerminateAfterSiblingStartedHook {
2907        sibling_started: Arc<tokio::sync::Notify>,
2908    }
2909    impl<M: CompletionModel> AgentHook<M> for TerminateAfterSiblingStartedHook {
2910        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
2911            if let StepEvent::ToolResult { args, .. } = event
2912                && serde_json::from_str::<serde_json::Value>(args)
2913                    .ok()
2914                    .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
2915                    == Some(1)
2916            {
2917                self.sibling_started.notified().await;
2918                return Flow::terminate("stop after a tool result");
2919            }
2920            Flow::cont()
2921        }
2922    }
2923
2924    /// A probe tool for the concurrent drain path: records how many calls
2925    /// `started` and `completed`. The `x == 2` call signals it has started, then
2926    /// stays pending across several polls, so it is genuinely in flight — not
2927    /// merely not-yet-started — when the `x == 1` call's result terminates the
2928    /// run. A driver that **drains** the concurrent tool stream polls it to
2929    /// completion (`completed == 2`); one that **cancels** in-flight siblings
2930    /// would drop it mid-poll (`completed == 1`).
2931    #[derive(Clone)]
2932    struct DrainProbeTool {
2933        started: Arc<AtomicU32>,
2934        completed: Arc<AtomicU32>,
2935        slow_started: Arc<tokio::sync::Notify>,
2936    }
2937
2938    impl Tool for DrainProbeTool {
2939        const NAME: &'static str = "add";
2940        type Error = MockToolError;
2941        type Args = serde_json::Value;
2942        type Output = i32;
2943
2944        fn description(&self) -> String {
2945            MockAddTool.description()
2946        }
2947
2948        fn parameters(&self) -> serde_json::Value {
2949            MockAddTool.parameters()
2950        }
2951
2952        async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
2953            self.started.fetch_add(1, SeqCst);
2954            if args.get("x").and_then(serde_json::Value::as_i64) == Some(2) {
2955                // Signal that the slow sibling has started, then stay pending so
2956                // it is still executing when the fast call's result terminates.
2957                self.slow_started.notify_one();
2958                for _ in 0..8 {
2959                    tokio::task::yield_now().await;
2960                }
2961            }
2962            self.completed.fetch_add(1, SeqCst);
2963            Ok(0)
2964        }
2965    }
2966
2967    /// On the concurrent path, a terminate surfaces a `StreamingError`, ends the
2968    /// run with no final response, and — for a sibling that is **already in
2969    /// flight** — drains it to completion rather than cancelling it mid-poll (so
2970    /// no detached task is left running and the deterministic terminate reason
2971    /// still surfaces). The `x == 2` sibling signals it started before the
2972    /// `x == 1` result terminates, so `completed == 2` holds only under drain.
2973    #[tokio::test]
2974    async fn stream_concurrent_tool_result_terminate_drains_in_flight_siblings() {
2975        let started = Arc::new(AtomicU32::new(0));
2976        let completed = Arc::new(AtomicU32::new(0));
2977        let slow_started = Arc::new(tokio::sync::Notify::new());
2978        let model = MockCompletionModel::from_stream_turns([
2979            vec![
2980                MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
2981                MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
2982                MockStreamEvent::final_response_with_total_tokens(0),
2983            ],
2984            vec![
2985                MockStreamEvent::text("done"),
2986                MockStreamEvent::final_response_with_total_tokens(0),
2987            ],
2988        ]);
2989        let mut stream = AgentBuilder::new(model)
2990            .tool(DrainProbeTool {
2991                started: started.clone(),
2992                completed: completed.clone(),
2993                slow_started: slow_started.clone(),
2994            })
2995            .build()
2996            .runner("add two pairs")
2997            .max_turns(3)
2998            .tool_concurrency(2)
2999            .add_hook(TerminateAfterSiblingStartedHook {
3000                sibling_started: slow_started,
3001            })
3002            .stream()
3003            .await;
3004
3005        let (saw_error, saw_final_response) =
3006            tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3007                let mut saw_error = false;
3008                let mut saw_final_response = false;
3009                while let Some(item) = stream.next().await {
3010                    match item {
3011                        Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final_response = true,
3012                        Ok(_) => {}
3013                        Err(StreamingError::Prompt(_)) => saw_error = true,
3014                        Err(other) => panic!("unexpected streaming error: {other}"),
3015                    }
3016                }
3017                (saw_error, saw_final_response)
3018            })
3019            .await
3020            .expect("draining the concurrent tools must not hang");
3021
3022        assert!(
3023            saw_error,
3024            "a terminate hook on the concurrent path must surface a StreamingError::Prompt"
3025        );
3026        assert!(
3027            !saw_final_response,
3028            "a terminated run must not yield a final response"
3029        );
3030        // The already-in-flight slow sibling is drained to completion, not
3031        // cancelled mid-poll (which would leave `completed == 1`).
3032        assert_eq!(
3033            started.load(SeqCst),
3034            2,
3035            "both tools started (both in flight)"
3036        );
3037        assert_eq!(
3038            completed.load(SeqCst),
3039            2,
3040            "the in-flight sibling must be drained to completion, not cancelled"
3041        );
3042    }
3043
3044    /// A `Flow::Terminate` from the `ToolCall` event with a reason keyed by the
3045    /// call's `x` arg, forcing the `x == 2` call (tc2) to terminate *before* the
3046    /// `x == 1` call (tc1): tc2 opens the gate after terminating, tc1 awaits it
3047    /// first. So completion order (tc2) differs from call order (tc1).
3048    struct OrderedTerminateHook {
3049        gate: Arc<tokio::sync::Notify>,
3050    }
3051
3052    impl<M: CompletionModel> AgentHook<M> for OrderedTerminateHook {
3053        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3054            if let StepEvent::ToolCall { args, .. } = event {
3055                let x = serde_json::from_str::<serde_json::Value>(args)
3056                    .ok()
3057                    .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64));
3058                match x {
3059                    Some(2) => {
3060                        self.gate.notify_one();
3061                        return Flow::Terminate {
3062                            reason: "terminated-by-tc2".to_string(),
3063                        };
3064                    }
3065                    Some(1) => {
3066                        self.gate.notified().await;
3067                        return Flow::Terminate {
3068                            reason: "terminated-by-tc1".to_string(),
3069                        };
3070                    }
3071                    _ => {}
3072                }
3073            }
3074            Flow::cont()
3075        }
3076    }
3077
3078    fn two_terminating_tools_blocking_model() -> MockCompletionModel {
3079        MockCompletionModel::from_turns([
3080            MockTurn::from_contents([
3081                tool_call_content("tc1", json!({"x": 1, "y": 1})),
3082                tool_call_content("tc2", json!({"x": 2, "y": 2})),
3083            ])
3084            .expect("two tool calls is non-empty"),
3085            MockTurn::text("unreachable"),
3086        ])
3087    }
3088
3089    fn two_terminating_tools_streaming_model() -> MockCompletionModel {
3090        MockCompletionModel::from_stream_turns([
3091            vec![
3092                MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
3093                MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
3094                MockStreamEvent::final_response_with_total_tokens(0),
3095            ],
3096            vec![
3097                MockStreamEvent::text("unreachable"),
3098                MockStreamEvent::final_response_with_total_tokens(0),
3099            ],
3100        ])
3101    }
3102
3103    /// When two tool calls in one turn both terminate the run under
3104    /// `tool_concurrency > 1`, run() and stream() surface the **same** reason —
3105    /// the first-called tool's (call order), not whichever finished first. tc2
3106    /// terminates before tc1, so a completion-order pick would surface tc2's
3107    /// reason and the two drivers would disagree.
3108    #[tokio::test]
3109    async fn concurrent_simultaneous_tool_terminations_pick_call_order_on_both_drivers() {
3110        let run_err = tokio::time::timeout(
3111            std::time::Duration::from_secs(5),
3112            AgentBuilder::new(two_terminating_tools_blocking_model())
3113                .tool(MockAddTool)
3114                .build()
3115                .runner("go")
3116                .max_turns(3)
3117                .tool_concurrency(2)
3118                .add_hook(OrderedTerminateHook {
3119                    gate: Arc::new(tokio::sync::Notify::new()),
3120                })
3121                .run(),
3122        )
3123        .await
3124        .expect("blocking run must not hang")
3125        .expect_err("the run must terminate");
3126
3127        let mut stream = AgentBuilder::new(two_terminating_tools_streaming_model())
3128            .tool(MockAddTool)
3129            .build()
3130            .runner("go")
3131            .max_turns(3)
3132            .tool_concurrency(2)
3133            .add_hook(OrderedTerminateHook {
3134                gate: Arc::new(tokio::sync::Notify::new()),
3135            })
3136            .stream()
3137            .await;
3138
3139        let stream_err = tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3140            while let Some(item) = stream.next().await {
3141                if let Err(err) = item {
3142                    return Some(err);
3143                }
3144            }
3145            None
3146        })
3147        .await
3148        .expect("streamed run must not hang")
3149        .expect("the stream must surface a terminate error");
3150
3151        let run_msg = run_err.to_string();
3152        let stream_msg = stream_err.to_string();
3153        assert!(
3154            run_msg.contains("terminated-by-tc1"),
3155            "blocking run should surface the first-called tool's reason, got: {run_msg}"
3156        );
3157        assert!(
3158            stream_msg.contains("terminated-by-tc1"),
3159            "stream should surface the first-called tool's reason, got: {stream_msg}"
3160        );
3161        assert!(
3162            !run_msg.contains("terminated-by-tc2") && !stream_msg.contains("terminated-by-tc2"),
3163            "neither driver should surface the later-completing tool's reason"
3164        );
3165    }
3166
3167    /// Terminates the run from the `ToolCall` event of the first tool only
3168    /// (`x == 1`), letting any later tool through.
3169    struct TerminateOnFirstToolHook;
3170    impl<M: CompletionModel> AgentHook<M> for TerminateOnFirstToolHook {
3171        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3172            if let StepEvent::ToolCall { args, .. } = event
3173                && serde_json::from_str::<serde_json::Value>(args)
3174                    .ok()
3175                    .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
3176                    == Some(1)
3177            {
3178                return Flow::Terminate {
3179                    reason: "stop".to_string(),
3180                };
3181            }
3182            Flow::cont()
3183        }
3184    }
3185
3186    /// Fail-fast, lock-step across surfaces: on a multi-tool turn whose first
3187    /// tool's hook terminates the run, the SEQUENTIAL default (`tool_concurrency`
3188    /// == 1) surfaces the terminate immediately and does **not** start the
3189    /// remaining sibling tools — so tool B's side effect never runs. The
3190    /// terminating tool's own body never runs either (its `ToolCall` hook fired
3191    /// first), so `calls == 0` on both drivers, which share the tool driver.
3192    #[tokio::test]
3193    async fn default_concurrency_terminate_skips_remaining_tools_on_both_drivers() {
3194        let blocking_calls = Arc::new(AtomicU32::new(0));
3195        AgentBuilder::new(two_terminating_tools_blocking_model())
3196            .tool(CountingAddTool {
3197                calls: blocking_calls.clone(),
3198            })
3199            .build()
3200            .runner("go")
3201            .max_turns(3)
3202            .add_hook(TerminateOnFirstToolHook)
3203            .run()
3204            .await
3205            .expect_err("the run terminates");
3206        assert_eq!(
3207            blocking_calls.load(SeqCst),
3208            0,
3209            "fail-fast: blocking run() must not start the second tool after the first terminates"
3210        );
3211
3212        let streaming_calls = Arc::new(AtomicU32::new(0));
3213        let mut stream = AgentBuilder::new(two_terminating_tools_streaming_model())
3214            .tool(CountingAddTool {
3215                calls: streaming_calls.clone(),
3216            })
3217            .build()
3218            .runner("go")
3219            .max_turns(3)
3220            .add_hook(TerminateOnFirstToolHook)
3221            .stream()
3222            .await;
3223        let mut saw_error = false;
3224        while let Some(item) = stream.next().await {
3225            if let Err(err) = item {
3226                saw_error = true;
3227                assert!(
3228                    err.to_string().contains("stop"),
3229                    "stream() should surface the terminate reason, got: {err}"
3230                );
3231                break;
3232            }
3233        }
3234        assert!(saw_error, "stream() must surface the terminate error");
3235        assert_eq!(
3236            streaming_calls.load(SeqCst),
3237            0,
3238            "fail-fast: stream() must not start the second tool after the first terminates"
3239        );
3240    }
3241
3242    /// Records the `x` arg of every tool call that reaches its body. The `x == 1`
3243    /// sibling signals it has started (via `sibling_started`) and then stays
3244    /// pending across several polls, so it is genuinely in flight when the
3245    /// terminator (`x == 0`) fires — while a sibling beyond the concurrency
3246    /// window is not yet started and must be dropped.
3247    #[derive(Clone)]
3248    struct RecordingArgsTool {
3249        called: Arc<Mutex<Vec<i64>>>,
3250        sibling_started: Arc<tokio::sync::Notify>,
3251    }
3252
3253    impl Tool for RecordingArgsTool {
3254        const NAME: &'static str = "add";
3255        type Error = MockToolError;
3256        type Args = serde_json::Value;
3257        type Output = i32;
3258
3259        fn description(&self) -> String {
3260            MockAddTool.description()
3261        }
3262
3263        fn parameters(&self) -> serde_json::Value {
3264            MockAddTool.parameters()
3265        }
3266
3267        async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
3268            let x = args.get("x").and_then(serde_json::Value::as_i64);
3269            if let Some(x) = x {
3270                self.called.lock().expect("called").push(x);
3271            }
3272            if x == Some(1) {
3273                // Signal that the in-flight sibling has started, then stay pending
3274                // so it is still executing when the terminator fires.
3275                self.sibling_started.notify_one();
3276                for _ in 0..8 {
3277                    tokio::task::yield_now().await;
3278                }
3279            }
3280            Ok(0)
3281        }
3282    }
3283
3284    fn three_tools_first_terminates_streaming_model() -> MockCompletionModel {
3285        MockCompletionModel::from_stream_turns([
3286            vec![
3287                // tc0 (x==0) terminates on its ToolCall hook after the in-flight
3288                // sibling starts; tc1 (x==1) is the in-flight sibling (drains);
3289                // tc2 (x==2) is beyond the concurrency-2 window (not yet started)
3290                // and must be dropped once tc0 terminates.
3291                MockStreamEvent::tool_call("tc0", "add", json!({"x": 0, "y": 0})),
3292                MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
3293                MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
3294                MockStreamEvent::final_response_with_total_tokens(0),
3295            ],
3296            vec![
3297                MockStreamEvent::text("unreachable"),
3298                MockStreamEvent::final_response_with_total_tokens(0),
3299            ],
3300        ])
3301    }
3302
3303    /// Terminates from the `x == 0` tool's `ToolCall` hook, but only after the
3304    /// `x == 1` sibling has signalled it started executing — so tc1 is genuinely
3305    /// in flight (not merely not-yet-started) when the terminate fires.
3306    struct TerminateOnArgZeroAfterSiblingHook {
3307        sibling_started: Arc<tokio::sync::Notify>,
3308    }
3309    impl<M: CompletionModel> AgentHook<M> for TerminateOnArgZeroAfterSiblingHook {
3310        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3311            if let StepEvent::ToolCall { args, .. } = event
3312                && serde_json::from_str::<serde_json::Value>(args)
3313                    .ok()
3314                    .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
3315                    == Some(0)
3316            {
3317                self.sibling_started.notified().await;
3318                return Flow::terminate("stop");
3319            }
3320            Flow::cont()
3321        }
3322    }
3323
3324    /// Concurrent fail-fast: when a tool terminates the turn under
3325    /// `tool_concurrency > 1`, an **already-in-flight** sibling is drained while a
3326    /// sibling **beyond the concurrency window** — not yet started — is dropped.
3327    /// With concurrency 2 and three tools: tc0 (`x == 0`) terminates only after
3328    /// tc1 (`x == 1`) has started, so tc1 is genuinely in flight and drains
3329    /// (`called` contains 1); tc2 (`x == 2`) is pulled only after tc0 frees a slot
3330    /// — by which time the run is terminating — so it is dropped (`called` never
3331    /// contains 2), and tc0's own body never runs (its `ToolCall` hook terminated).
3332    /// The pre-fix run-all-then-decide would have executed tc2 too.
3333    #[tokio::test]
3334    async fn concurrent_terminate_drops_beyond_window_sibling_but_drains_in_flight() {
3335        let called = Arc::new(Mutex::new(Vec::new()));
3336        let sibling_started = Arc::new(tokio::sync::Notify::new());
3337        let mut stream = AgentBuilder::new(three_tools_first_terminates_streaming_model())
3338            .tool(RecordingArgsTool {
3339                called: called.clone(),
3340                sibling_started: sibling_started.clone(),
3341            })
3342            .build()
3343            .runner("go")
3344            .max_turns(3)
3345            .tool_concurrency(2)
3346            .add_hook(TerminateOnArgZeroAfterSiblingHook { sibling_started })
3347            .stream()
3348            .await;
3349
3350        let (saw_error, saw_final) =
3351            tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3352                let mut saw_error = false;
3353                let mut saw_final = false;
3354                while let Some(item) = stream.next().await {
3355                    match item {
3356                        Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final = true,
3357                        Ok(_) => {}
3358                        Err(_) => saw_error = true,
3359                    }
3360                }
3361                (saw_error, saw_final)
3362            })
3363            .await
3364            .expect("the concurrent tool drive must not hang");
3365
3366        assert!(saw_error, "the terminated run must surface an error");
3367        assert!(
3368            !saw_final,
3369            "a terminated run must not yield a final response"
3370        );
3371        let called = called.lock().expect("called").clone();
3372        assert!(
3373            called.contains(&1),
3374            "the in-flight sibling (x==1) must be drained to completion; called args: {called:?}"
3375        );
3376        assert!(
3377            !called.contains(&2),
3378            "the not-yet-started sibling beyond the concurrency window (x==2) must be \
3379             dropped, not executed; called args: {called:?}"
3380        );
3381        assert!(
3382            !called.contains(&0),
3383            "the terminator's own body never runs (its ToolCall hook terminated); \
3384             called args: {called:?}"
3385        );
3386    }
3387
3388    /// A tool that, for the `x == 1` call, records it ran and signals a gate; the
3389    /// terminating sibling waits on that gate so the `x == 1` call completes
3390    /// *before* the batch terminates.
3391    #[derive(Clone)]
3392    struct SignalOnRunTool {
3393        a_ran: Arc<AtomicU32>,
3394        a_done: Arc<tokio::sync::Notify>,
3395    }
3396    impl Tool for SignalOnRunTool {
3397        const NAME: &'static str = "add";
3398        type Error = MockToolError;
3399        type Args = serde_json::Value;
3400        type Output = i32;
3401        fn description(&self) -> String {
3402            MockAddTool.description()
3403        }
3404
3405        fn parameters(&self) -> serde_json::Value {
3406            MockAddTool.parameters()
3407        }
3408        async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
3409            if args.get("x").and_then(serde_json::Value::as_i64) == Some(1) {
3410                self.a_ran.fetch_add(1, SeqCst);
3411                self.a_done.notify_one();
3412            }
3413            Ok(0)
3414        }
3415    }
3416
3417    /// The `x == 2` tool's `ToolCall` hook terminates, but only after the `x == 1`
3418    /// sibling has finished (via the gate), so a *completed* sibling's result is
3419    /// still suppressed by the atomic batch.
3420    struct TerminateAfterSiblingDoneHook {
3421        a_done: Arc<tokio::sync::Notify>,
3422    }
3423    impl<M: CompletionModel> AgentHook<M> for TerminateAfterSiblingDoneHook {
3424        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3425            if let StepEvent::ToolCall { args, .. } = event
3426                && serde_json::from_str::<serde_json::Value>(args)
3427                    .ok()
3428                    .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
3429                    == Some(2)
3430            {
3431                self.a_done.notified().await;
3432                return Flow::terminate("stop");
3433            }
3434            Flow::cont()
3435        }
3436    }
3437
3438    /// Atomic concurrent batch: when the batch terminates, even a sibling that
3439    /// completed **successfully** before the terminating sibling produces no
3440    /// `ToolExecutionStart` and no `ToolResult` stream item (no orphan
3441    /// execution-start), and its result is not committed. The `x == 1` tool runs
3442    /// to completion (its side effect happens) and signals; the `x == 2` tool's
3443    /// hook then terminates.
3444    #[tokio::test]
3445    async fn concurrent_termination_surfaces_no_execution_items() {
3446        let a_ran = Arc::new(AtomicU32::new(0));
3447        let a_done = Arc::new(tokio::sync::Notify::new());
3448        let model = MockCompletionModel::from_stream_turns([
3449            vec![
3450                MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
3451                MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
3452                MockStreamEvent::final_response_with_total_tokens(0),
3453            ],
3454            vec![
3455                MockStreamEvent::text("unreachable"),
3456                MockStreamEvent::final_response_with_total_tokens(0),
3457            ],
3458        ]);
3459        let mut stream = AgentBuilder::new(model)
3460            .tool(SignalOnRunTool {
3461                a_ran: a_ran.clone(),
3462                a_done: a_done.clone(),
3463            })
3464            .build()
3465            .runner("go")
3466            .max_turns(3)
3467            .tool_concurrency(2)
3468            .add_hook(TerminateAfterSiblingDoneHook {
3469                a_done: a_done.clone(),
3470            })
3471            .stream()
3472            .await;
3473
3474        let (exec_starts, results, saw_error, saw_final) =
3475            tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3476                let (mut exec_starts, mut results, mut saw_error, mut saw_final) =
3477                    (0, 0, false, false);
3478                while let Some(item) = stream.next().await {
3479                    match item {
3480                        Ok(MultiTurnStreamItem::ToolExecutionStart { .. }) => exec_starts += 1,
3481                        Ok(MultiTurnStreamItem::StreamUserItem(
3482                            StreamedUserContent::ToolResult { .. },
3483                        )) => results += 1,
3484                        Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final = true,
3485                        Ok(_) => {}
3486                        Err(_) => saw_error = true,
3487                    }
3488                }
3489                (exec_starts, results, saw_error, saw_final)
3490            })
3491            .await
3492            .expect("the concurrent tool drive must not hang");
3493
3494        assert!(saw_error, "the terminated run must surface an error");
3495        assert!(
3496            !saw_final,
3497            "a terminated run must not yield a final response"
3498        );
3499        assert_eq!(
3500            exec_starts, 0,
3501            "a terminated batch surfaces no ToolExecutionStart (no orphan start events)"
3502        );
3503        assert_eq!(
3504            results, 0,
3505            "a terminated batch surfaces no successful ToolResult"
3506        );
3507        assert_eq!(
3508            a_ran.load(SeqCst),
3509            1,
3510            "the fast sibling did run (its side effect happened), but its result was suppressed"
3511        );
3512    }
3513
3514    /// The model tool-call event carries the model's **original** arguments; the
3515    /// execution-start event carries the **effective** (hook-rewritten) arguments
3516    /// — so a `RewriteArgs` rewrite (e.g. a redaction) is reflected in what
3517    /// actually ran, not leaked as the original.
3518    #[tokio::test]
3519    async fn stream_tool_execution_start_carries_effective_rewritten_args() {
3520        let model = MockCompletionModel::from_stream_turns([
3521            vec![
3522                MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
3523                MockStreamEvent::final_response_with_total_tokens(0),
3524            ],
3525            vec![
3526                MockStreamEvent::text("done"),
3527                MockStreamEvent::final_response_with_total_tokens(0),
3528            ],
3529        ]);
3530        let mut stream = AgentBuilder::new(model)
3531            .tool(MockAddTool)
3532            .add_hook(RewriteToolArgsHook(json!({"x": 2, "y": 40})))
3533            .build()
3534            .runner("go")
3535            .max_turns(3)
3536            .stream()
3537            .await;
3538
3539        let mut model_args = None;
3540        let mut exec_args = None;
3541        while let Some(item) = stream.next().await {
3542            match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
3543                MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall {
3544                    tool_call,
3545                    ..
3546                }) => model_args = Some(tool_call.function.arguments),
3547                MultiTurnStreamItem::ToolExecutionStart { tool_call, .. } => {
3548                    exec_args = Some(tool_call.function.arguments)
3549                }
3550                _ => {}
3551            }
3552        }
3553        assert_eq!(
3554            model_args,
3555            Some(json!({"x": 2, "y": 3})),
3556            "the model tool-call event carries the model's original arguments"
3557        );
3558        assert_eq!(
3559            exec_args,
3560            Some(json!({"x": 2, "y": 40})),
3561            "the execution-start event carries the hook-rewritten (effective) arguments"
3562        );
3563    }
3564
3565    /// A `ToolCall` hook `Flow::Skip` surfaces the skip result as a `ToolResult`
3566    /// (the model sees it, and it is committed to history) but produces **no**
3567    /// `ToolExecutionStart` — nothing actually ran.
3568    #[tokio::test]
3569    async fn stream_hook_skip_surfaces_result_without_execution_start() {
3570        struct SkipHook;
3571        impl<M: CompletionModel> AgentHook<M> for SkipHook {
3572            async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3573                if let StepEvent::ToolCall { .. } = event {
3574                    Flow::skip("blocked by policy")
3575                } else {
3576                    Flow::cont()
3577                }
3578            }
3579        }
3580
3581        let calls = Arc::new(AtomicU32::new(0));
3582        let model = MockCompletionModel::from_stream_turns([
3583            vec![
3584                MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 2})),
3585                MockStreamEvent::final_response_with_total_tokens(0),
3586            ],
3587            vec![
3588                MockStreamEvent::text("done"),
3589                MockStreamEvent::final_response_with_total_tokens(0),
3590            ],
3591        ]);
3592        let stream = AgentBuilder::new(model)
3593            .tool(CountingAddTool {
3594                calls: calls.clone(),
3595            })
3596            .add_hook(SkipHook)
3597            .build()
3598            .runner("go")
3599            .max_turns(3)
3600            .stream()
3601            .await;
3602
3603        let mut exec_starts = 0;
3604        let mut results = 0;
3605        let mut final_response = None;
3606        let mut stream = stream;
3607        while let Some(item) = stream.next().await {
3608            match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
3609                MultiTurnStreamItem::ToolExecutionStart { .. } => exec_starts += 1,
3610                MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult { .. }) => {
3611                    results += 1
3612                }
3613                MultiTurnStreamItem::FinalResponse(resp) => final_response = Some(resp),
3614                _ => {}
3615            }
3616        }
3617
3618        assert_eq!(calls.load(SeqCst), 0, "a skipped tool's body never runs");
3619        assert_eq!(
3620            exec_starts, 0,
3621            "a hook-skipped tool produces no execution-start"
3622        );
3623        assert_eq!(
3624            results, 1,
3625            "the skip result is still surfaced to the consumer"
3626        );
3627        let final_response = final_response.expect("stream should yield a final response");
3628        // The skip result is committed to history (the model sees the reason).
3629        let history = final_response.messages().expect("history");
3630        assert!(
3631            history.iter().any(|m| serde_json::to_string(m)
3632                .map(|s| s.contains("blocked by policy"))
3633                .unwrap_or(false)),
3634            "the skip result is committed to history"
3635        );
3636    }
3637
3638    /// `ToolChoice::Required` + a hook whose `active_tools([])` advertises no tools
3639    /// is a **local** error: the run fails before any provider round-trip.
3640    #[tokio::test]
3641    async fn required_with_empty_active_tools_errors_locally_without_provider_call() {
3642        struct EmptyActiveToolsHook;
3643        impl<M: CompletionModel> AgentHook<M> for EmptyActiveToolsHook {
3644            async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3645                if let StepEvent::CompletionCall { .. } = event {
3646                    Flow::patch_request(RequestPatch::new().active_tools(Vec::<String>::new()))
3647                } else {
3648                    Flow::cont()
3649                }
3650            }
3651        }
3652
3653        let model = MockCompletionModel::from_turns([MockTurn::text("unreachable")]);
3654        let probe = model.clone();
3655        let err = AgentBuilder::new(model)
3656            .tool(MockAddTool)
3657            .tool_choice(ToolChoice::Required)
3658            .add_hook(EmptyActiveToolsHook)
3659            .build()
3660            .runner("go")
3661            .run()
3662            .await
3663            .expect_err("Required with an empty active_tools filter must fail locally");
3664
3665        assert!(
3666            probe.requests().is_empty(),
3667            "the request must fail locally, with no provider round-trip"
3668        );
3669        let msg = err.to_string();
3670        assert!(
3671            msg.contains("Required"),
3672            "error should mention Required: {msg}"
3673        );
3674        assert!(
3675            msg.contains("active_tools"),
3676            "error should name active_tools: {msg}"
3677        );
3678    }
3679
3680    /// `ToolChoice::Specific` naming a tool that a hook's `active_tools` filtered
3681    /// out is a **local** error naming the filter, before any provider round-trip.
3682    #[tokio::test]
3683    async fn specific_naming_filtered_out_tool_errors_locally_without_provider_call() {
3684        struct FilterToAddHook;
3685        impl<M: CompletionModel> AgentHook<M> for FilterToAddHook {
3686            async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3687                if let StepEvent::CompletionCall { .. } = event {
3688                    Flow::patch_request(RequestPatch::new().active_tools(["add"]))
3689                } else {
3690                    Flow::cont()
3691                }
3692            }
3693        }
3694
3695        let model = MockCompletionModel::from_turns([MockTurn::text("unreachable")]);
3696        let probe = model.clone();
3697        let err = AgentBuilder::new(model)
3698            .tool(MockAddTool)
3699            .tool(MockSubtractTool)
3700            .tool_choice(ToolChoice::Specific {
3701                function_names: vec!["subtract".to_string()],
3702            })
3703            .add_hook(FilterToAddHook)
3704            .build()
3705            .runner("go")
3706            .run()
3707            .await
3708            .expect_err("Specific naming a filtered-out tool must fail locally");
3709
3710        assert!(
3711            probe.requests().is_empty(),
3712            "the request must fail locally, with no provider round-trip"
3713        );
3714        let msg = err.to_string();
3715        assert!(
3716            msg.contains("subtract"),
3717            "error should name the missing tool: {msg}"
3718        );
3719        assert!(
3720            msg.contains("active_tools"),
3721            "error should name active_tools: {msg}"
3722        );
3723    }
3724
3725    /// `tool_concurrency(0)` is clamped to 1 and runs to completion. The timeout
3726    /// guards against a regression that lets `concurrency == 0` reach a
3727    /// `buffer_unordered(0)` (which never makes progress) instead of the
3728    /// sequential `concurrency <= 1` path.
3729    #[tokio::test]
3730    async fn tool_concurrency_zero_is_clamped_and_does_not_hang() {
3731        let model = MockCompletionModel::from_turns([
3732            MockTurn::tool_call("tc1", "add", json!({"x": 1, "y": 2})),
3733            MockTurn::text("done"),
3734        ]);
3735        let run = AgentBuilder::new(model)
3736            .tool(MockAddTool)
3737            .build()
3738            .runner("add")
3739            .max_turns(3)
3740            .tool_concurrency(0)
3741            .run();
3742
3743        let response = tokio::time::timeout(std::time::Duration::from_secs(5), run)
3744            .await
3745            .expect("tool_concurrency(0) must clamp to 1, not hang on buffer_unordered(0)")
3746            .expect("run should succeed");
3747        assert_eq!(response.output, "done");
3748    }
3749
3750    /// A tool that counts how many times it actually executes.
3751    #[derive(Clone)]
3752    struct CountingAddTool {
3753        calls: Arc<AtomicU32>,
3754    }
3755
3756    impl Tool for CountingAddTool {
3757        const NAME: &'static str = "add";
3758        type Error = MockToolError;
3759        type Args = MockOperationArgs;
3760        type Output = i32;
3761
3762        fn description(&self) -> String {
3763            MockAddTool.description()
3764        }
3765
3766        fn parameters(&self) -> serde_json::Value {
3767            MockAddTool.parameters()
3768        }
3769
3770        async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
3771            self.calls.fetch_add(1, SeqCst);
3772            Ok(0)
3773        }
3774    }
3775
3776    struct FailOnToolCallHook;
3777    impl<M: CompletionModel> AgentHook<M> for FailOnToolCallHook {
3778        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3779            if let StepEvent::ToolCall { .. } = event {
3780                // Fail is illegal for a ToolCall event: the runner must be
3781                // fail-CLOSED and never execute the tool.
3782                Flow::fail()
3783            } else {
3784                Flow::cont()
3785            }
3786        }
3787    }
3788
3789    /// A hook returning `Flow::Fail` for a tool call (an action that is not
3790    /// honored for that event) terminates the run fail-closed — the tool never
3791    /// executes.
3792    #[tokio::test]
3793    async fn tool_call_fail_is_fail_closed() {
3794        let calls = Arc::new(AtomicU32::new(0));
3795        let model = MockCompletionModel::from_turns([MockTurn::tool_call(
3796            "tc1",
3797            "add",
3798            json!({"x": 1, "y": 2}),
3799        )]);
3800        let agent = AgentBuilder::new(model)
3801            .tool(CountingAddTool {
3802                calls: calls.clone(),
3803            })
3804            .build();
3805
3806        let err = agent
3807            .runner("add")
3808            .max_turns(2)
3809            .add_hook(FailOnToolCallHook)
3810            .run()
3811            .await
3812            .expect_err("fail-closed: the run must error rather than execute the tool");
3813
3814        assert!(matches!(err, PromptError::PromptCancelled { .. }));
3815        assert_eq!(
3816            calls.load(SeqCst),
3817            0,
3818            "tool must not execute when a hook returns Fail for a tool call"
3819        );
3820    }
3821
3822    #[derive(Clone, Default)]
3823    struct ToolOnlyHook {
3824        text_delta_calls: Arc<AtomicU32>,
3825        other_calls: Arc<AtomicU32>,
3826    }
3827
3828    impl<M: CompletionModel> AgentHook<M> for ToolOnlyHook {
3829        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3830            match event.kind() {
3831                StepEventKind::TextDelta => {
3832                    self.text_delta_calls.fetch_add(1, SeqCst);
3833                }
3834                _ => {
3835                    self.other_calls.fetch_add(1, SeqCst);
3836                }
3837            }
3838            Flow::cont()
3839        }
3840
3841        fn observes(&self, kind: StepEventKind) -> bool {
3842            kind != StepEventKind::TextDelta
3843        }
3844    }
3845
3846    /// A hook that declares it does not observe text deltas is never dispatched
3847    /// for them (the runner skips building/dispatching that event), but still
3848    /// receives the events it does observe.
3849    #[tokio::test]
3850    async fn observes_gates_text_delta_dispatch() {
3851        let model = MockCompletionModel::from_stream_turns([vec![
3852            MockStreamEvent::text("hel"),
3853            MockStreamEvent::text("lo"),
3854            MockStreamEvent::final_response_with_total_tokens(0),
3855        ]]);
3856        let hook = ToolOnlyHook::default();
3857        let mut stream = AgentBuilder::new(model)
3858            .build()
3859            .runner("hi")
3860            .add_hook(hook.clone())
3861            .stream()
3862            .await;
3863        while stream.next().await.is_some() {}
3864
3865        assert_eq!(
3866            hook.text_delta_calls.load(SeqCst),
3867            0,
3868            "a hook that does not observe TextDelta must not be dispatched for it"
3869        );
3870        assert!(
3871            hook.other_calls.load(SeqCst) > 0,
3872            "the hook should still receive the events it observes"
3873        );
3874    }
3875
3876    /// Terminates the run when it sees a chosen event kind, observing every other
3877    /// event as `Continue`.
3878    struct TerminateOn(StepEventKind);
3879
3880    impl<M: CompletionModel> AgentHook<M> for TerminateOn {
3881        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3882            if event.kind() == self.0 {
3883                Flow::terminate("stop here")
3884            } else {
3885                Flow::cont()
3886            }
3887        }
3888    }
3889
3890    /// `Flow::Terminate` cancels the blocking run from *every* shared driver
3891    /// event (model call, model response, tool call, tool result) — none is a
3892    /// silent no-op.
3893    #[tokio::test]
3894    async fn run_terminates_from_each_shared_event() {
3895        for kind in [
3896            StepEventKind::CompletionCall,
3897            StepEventKind::CompletionResponse,
3898            StepEventKind::ToolCall,
3899            StepEventKind::ToolResult,
3900        ] {
3901            let err = AgentBuilder::new(blocking_model())
3902                .tool(MockAddTool)
3903                .build()
3904                .runner("add 2 and 3")
3905                .max_turns(3)
3906                .add_hook(TerminateOn(kind))
3907                .run()
3908                .await
3909                .expect_err(&format!("terminate at {kind:?} must cancel the run"));
3910            assert!(
3911                matches!(err, PromptError::PromptCancelled { .. }),
3912                "terminate at {kind:?} should cancel the run, got {err:?}"
3913            );
3914        }
3915    }
3916
3917    /// The same fail-closed termination holds for the streaming driver across the
3918    /// shared events it fires (it surfaces `StreamResponseFinish` instead of
3919    /// `CompletionResponse`): each yields a stream error and no final response.
3920    #[tokio::test]
3921    async fn stream_terminates_from_each_shared_event() {
3922        for kind in [
3923            StepEventKind::CompletionCall,
3924            StepEventKind::ToolCall,
3925            StepEventKind::ToolResult,
3926        ] {
3927            let mut stream = AgentBuilder::new(streaming_model())
3928                .tool(MockAddTool)
3929                .build()
3930                .runner("add 2 and 3")
3931                .max_turns(3)
3932                .add_hook(TerminateOn(kind))
3933                .stream()
3934                .await;
3935
3936            let mut saw_error = false;
3937            let mut saw_final = false;
3938            while let Some(item) = stream.next().await {
3939                match item {
3940                    Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final = true,
3941                    Err(_) => saw_error = true,
3942                    _ => {}
3943                }
3944            }
3945            assert!(saw_error, "terminate at {kind:?} must yield a stream error");
3946            assert!(
3947                !saw_final,
3948                "terminate at {kind:?} must not also produce a final response"
3949            );
3950        }
3951    }
3952
3953    /// Two hooks pushed onto one stack both observe every event (no short-circuit
3954    /// on `Continue`), and the stack's shared event sequence is identical across
3955    /// the blocking and streaming drivers.
3956    #[tokio::test]
3957    async fn multi_hook_stack_parity_across_run_and_stream() {
3958        let a_block = RecordingHook::default();
3959        let b_block = RecordingHook::default();
3960        let blocking = AgentBuilder::new(blocking_model())
3961            .tool(MockAddTool)
3962            .build()
3963            .runner("add 2 and 3")
3964            .max_turns(3)
3965            .add_hook(a_block.clone())
3966            .add_hook(b_block.clone())
3967            .run()
3968            .await
3969            .expect("blocking run should succeed");
3970
3971        let a_stream = RecordingHook::default();
3972        let b_stream = RecordingHook::default();
3973        let mut stream = AgentBuilder::new(streaming_model())
3974            .tool(MockAddTool)
3975            .build()
3976            .runner("add 2 and 3")
3977            .max_turns(3)
3978            .add_hook(a_stream.clone())
3979            .add_hook(b_stream.clone())
3980            .stream()
3981            .await;
3982        while stream.next().await.is_some() {}
3983
3984        // Both hooks in the stack saw the same events (both ran on every Continue).
3985        assert_eq!(a_block.shared_events(), b_block.shared_events());
3986        assert_eq!(a_stream.shared_events(), b_stream.shared_events());
3987        // The stack's shared event sequence is identical across drivers.
3988        assert_eq!(a_block.shared_events(), a_stream.shared_events());
3989        assert_eq!(
3990            a_block.shared_events(),
3991            vec![
3992                StepEventKind::CompletionCall,
3993                StepEventKind::ToolCall,
3994                StepEventKind::ToolResult,
3995                StepEventKind::CompletionCall,
3996            ]
3997        );
3998        assert_eq!(blocking.output, "the answer is 5");
3999    }
4000
4001    /// Renames an invalid tool call to a known tool; observes everything else.
4002    struct RepairInvalidToHook(&'static str);
4003
4004    impl<M: CompletionModel> AgentHook<M> for RepairInvalidToHook {
4005        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4006            if let StepEvent::InvalidToolCall(_) = event {
4007                Flow::repair(self.0)
4008            } else {
4009                Flow::cont()
4010            }
4011        }
4012    }
4013
4014    /// An invalid tool call repaired by a hook recovers identically under run()
4015    /// and stream(): the renamed tool executes and both drivers reach the same
4016    /// output, tool-result content, and final message history.
4017    #[tokio::test]
4018    async fn invalid_tool_call_repair_parity_across_run_and_stream() {
4019        let blocking_model = MockCompletionModel::from_turns([
4020            MockTurn::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4021            MockTurn::text("the answer is 5"),
4022        ]);
4023        let blocking_hook = RecordingHook::default();
4024        let blocking = AgentBuilder::new(blocking_model)
4025            .tool(MockAddTool)
4026            .build()
4027            .runner("add 2 and 3")
4028            .max_turns(3)
4029            .add_hook(blocking_hook.clone())
4030            .add_hook(RepairInvalidToHook("add"))
4031            .run()
4032            .await
4033            .expect("blocking run should recover via repair");
4034
4035        // Emit the invalid call as a single complete tool call (mirroring the
4036        // blocking model). A provider stream carries one tool call via one
4037        // mechanism — deltas *or* a complete call — so this is the apples-to-
4038        // apples comparison; mixing both would trip the assembler's two
4039        // independent invalid-detection sites and fire the event twice.
4040        let streaming_model = MockCompletionModel::from_stream_turns([
4041            vec![
4042                MockStreamEvent::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4043                MockStreamEvent::final_response_with_total_tokens(0),
4044            ],
4045            vec![
4046                MockStreamEvent::text("the answer is 5"),
4047                MockStreamEvent::final_response_with_total_tokens(0),
4048            ],
4049        ]);
4050        let streaming_hook = RecordingHook::default();
4051        let mut stream = AgentBuilder::new(streaming_model)
4052            .tool(MockAddTool)
4053            .build()
4054            .runner("add 2 and 3")
4055            .max_turns(3)
4056            .add_hook(streaming_hook.clone())
4057            .add_hook(RepairInvalidToHook("add"))
4058            .stream()
4059            .await;
4060        let mut final_response = None;
4061        while let Some(item) = stream.next().await {
4062            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4063                item.map_err(|err| panic!("stream item errored: {err}"))
4064            {
4065                final_response = Some(resp);
4066            }
4067        }
4068        let final_response =
4069            final_response.expect("stream should recover and yield a final response");
4070
4071        // Same recovered output.
4072        assert_eq!(blocking.output, "the answer is 5");
4073        assert_eq!(final_response.output(), blocking.output);
4074
4075        // Both drivers reported the invalid tool call to the hook, then executed
4076        // the repaired tool, so the shared event sequences match.
4077        assert_eq!(
4078            blocking_hook.shared_events(),
4079            streaming_hook.shared_events()
4080        );
4081        assert!(
4082            blocking_hook
4083                .shared_events()
4084                .contains(&StepEventKind::InvalidToolCall),
4085            "the hook must observe the invalid tool call"
4086        );
4087        assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4088        assert_eq!(blocking_hook.tool_results(), vec!["5".to_string()]);
4089
4090        // Same final message history.
4091        let blocking_messages = blocking.messages.expect("blocking messages");
4092        let streaming_messages = final_response
4093            .messages()
4094            .expect("streaming history")
4095            .to_vec();
4096        assert_eq!(
4097            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4098            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4099        );
4100    }
4101
4102    // ----------------------------------------------------------------------
4103    // Single-source-of-truth parity harness
4104    // ----------------------------------------------------------------------
4105    //
4106    // `run()` and `stream()` are two implementations of one agent loop; testing
4107    // they agree on the same input is *differential testing*, with each driver
4108    // acting as the other's oracle. The hazard such tests have (and that bit the
4109    // invalid-tool-repair test above) is *fixture drift*: when the blocking
4110    // `MockTurn` list and the streaming `MockStreamEvent` list are hand-written
4111    // separately, they can silently encode different model behavior, so a
4112    // passing test proves nothing.
4113    //
4114    // The fix — the single-source-of-truth / data-driven principle, embodied by
4115    // pydantic-ai's `TestModel` (one scripted response replayed as a stream) and
4116    // litellm's `stream_chunk_builder` (reassemble the stream, compare to the
4117    // whole) — is to derive *both* encodings from one canonical `ScriptedTurn`
4118    // list. The two drivers are then provably fed identical model behavior and
4119    // can be asserted equal on the medium-independent projection (final output,
4120    // message history, tool-result content, shared hook-event sequence).
4121
4122    /// One tool call inside a scripted turn.
4123    #[derive(Clone)]
4124    struct ScriptedToolCall {
4125        id: &'static str,
4126        name: &'static str,
4127        args: serde_json::Value,
4128    }
4129
4130    /// One scripted model turn, described once and rendered into both a blocking
4131    /// `MockTurn` and a streaming `Vec<MockStreamEvent>`.
4132    #[derive(Clone)]
4133    enum ScriptedTurn {
4134        /// A final text answer.
4135        Text(&'static str),
4136        /// One or more tool calls emitted in a single turn.
4137        ToolCalls(Vec<ScriptedToolCall>),
4138    }
4139
4140    /// How a tool call is rendered onto the wire for the streaming driver. Both
4141    /// shapes must yield the *same* canonical turn ("chunked-input invariance",
4142    /// the `tokio-util` `LengthDelimitedCodec` lesson): the assembled message
4143    /// history and tool results may not depend on whether a provider sends a
4144    /// complete tool call or streams it as deltas.
4145    #[derive(Clone, Copy)]
4146    enum StreamShape {
4147        /// One complete tool-call event per call (mirrors the blocking turn).
4148        Complete,
4149        /// Name + argument deltas followed by the complete call, additionally
4150        /// exercising the delta-hook path and the assembler's delta buffering.
4151        Chunked,
4152    }
4153
4154    impl ScriptedTurn {
4155        fn as_blocking_turn(&self) -> MockTurn {
4156            match self {
4157                ScriptedTurn::Text(text) => MockTurn::text(*text),
4158                ScriptedTurn::ToolCalls(calls) => {
4159                    MockTurn::from_contents(calls.iter().map(|call| {
4160                        AssistantContent::ToolCall(ToolCall::new(
4161                            call.id.to_string(),
4162                            ToolFunction::new(call.name.to_string(), call.args.clone()),
4163                        ))
4164                    }))
4165                    .expect("a scripted tool-call turn has at least one call")
4166                }
4167            }
4168        }
4169
4170        fn as_stream_events(&self, shape: StreamShape) -> Vec<MockStreamEvent> {
4171            let mut events = Vec::new();
4172            match self {
4173                ScriptedTurn::Text(text) => events.push(MockStreamEvent::text(*text)),
4174                ScriptedTurn::ToolCalls(calls) => {
4175                    for call in calls {
4176                        if let StreamShape::Chunked = shape {
4177                            // Distinct internal id per call; the canonical args
4178                            // still come from the complete event below, so this
4179                            // exercises the delta path without changing the turn.
4180                            let internal = format!("ic-{}", call.id);
4181                            let args = serde_json::to_string(&call.args)
4182                                .expect("scripted args serialize to json");
4183                            events.push(MockStreamEvent::tool_call_name_delta(
4184                                call.id, &internal, call.name,
4185                            ));
4186                            events.push(MockStreamEvent::tool_call_arguments_delta(
4187                                call.id, &internal, &args,
4188                            ));
4189                        }
4190                        events.push(MockStreamEvent::tool_call(
4191                            call.id,
4192                            call.name,
4193                            call.args.clone(),
4194                        ));
4195                    }
4196                }
4197            }
4198            events.push(MockStreamEvent::final_response_with_total_tokens(0));
4199            events
4200        }
4201    }
4202
4203    /// The medium-independent projection of a run that both drivers must agree
4204    /// on.
4205    struct ParityOutcome {
4206        output: String,
4207        messages: Vec<Message>,
4208        shared_events: Vec<StepEventKind>,
4209        tool_results: Vec<String>,
4210    }
4211
4212    async fn run_blocking_scenario(prompt: &'static str, turns: &[ScriptedTurn]) -> ParityOutcome {
4213        let model =
4214            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4215        let hook = RecordingHook::default();
4216        let response = AgentBuilder::new(model)
4217            .tool(MockAddTool)
4218            .build()
4219            .runner(prompt)
4220            .max_turns(8)
4221            .add_hook(hook.clone())
4222            .run()
4223            .await
4224            .expect("blocking scenario should succeed");
4225        ParityOutcome {
4226            output: response.output,
4227            messages: response.messages.expect("blocking messages"),
4228            shared_events: hook.shared_events(),
4229            tool_results: hook.tool_results(),
4230        }
4231    }
4232
4233    async fn run_streaming_scenario(
4234        prompt: &'static str,
4235        turns: &[ScriptedTurn],
4236        shape: StreamShape,
4237    ) -> ParityOutcome {
4238        let model = MockCompletionModel::from_stream_turns(
4239            turns.iter().map(|turn| turn.as_stream_events(shape)),
4240        );
4241        let hook = RecordingHook::default();
4242        let mut stream = AgentBuilder::new(model)
4243            .tool(MockAddTool)
4244            .build()
4245            .runner(prompt)
4246            .max_turns(8)
4247            .add_hook(hook.clone())
4248            .stream()
4249            .await;
4250        let mut final_response = None;
4251        while let Some(item) = stream.next().await {
4252            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4253                item.map_err(|err| panic!("stream item errored: {err}"))
4254            {
4255                final_response = Some(resp);
4256            }
4257        }
4258        let final_response =
4259            final_response.expect("streaming scenario should yield a final response");
4260        ParityOutcome {
4261            output: final_response.output().to_string(),
4262            messages: final_response
4263                .messages()
4264                .expect("streaming history")
4265                .to_vec(),
4266            shared_events: hook.shared_events(),
4267            tool_results: hook.tool_results(),
4268        }
4269    }
4270
4271    fn assert_outcomes_match(blocking: &ParityOutcome, streaming: &ParityOutcome, label: &str) {
4272        assert_eq!(
4273            blocking.output, streaming.output,
4274            "{label}: final output diverged"
4275        );
4276        assert_eq!(
4277            blocking.shared_events, streaming.shared_events,
4278            "{label}: hook event sequence diverged"
4279        );
4280        assert_eq!(
4281            blocking.tool_results, streaming.tool_results,
4282            "{label}: tool-result content diverged"
4283        );
4284        assert_eq!(
4285            serde_json::to_value(&blocking.messages).expect("serialize blocking"),
4286            serde_json::to_value(&streaming.messages).expect("serialize streaming"),
4287            "{label}: message history diverged"
4288        );
4289    }
4290
4291    /// Drive one canonical scenario through `run()` and through `stream()` in
4292    /// both wire shapes, asserting the medium-independent projection is
4293    /// identical every way. Because both stream shapes are compared against the
4294    /// same blocking outcome, they are also transitively equal to each other.
4295    async fn assert_run_stream_parity(prompt: &'static str, turns: &[ScriptedTurn]) {
4296        let blocking = run_blocking_scenario(prompt, turns).await;
4297        for (shape, label) in [
4298            (StreamShape::Complete, "complete-stream"),
4299            (StreamShape::Chunked, "chunked-stream"),
4300        ] {
4301            let streaming = run_streaming_scenario(prompt, turns, shape).await;
4302            assert_outcomes_match(&blocking, &streaming, label);
4303        }
4304    }
4305
4306    fn add_call(id: &'static str, x: i64, y: i64) -> ScriptedToolCall {
4307        ScriptedToolCall {
4308            id,
4309            name: "add",
4310            args: json!({ "x": x, "y": y }),
4311        }
4312    }
4313
4314    #[tokio::test]
4315    async fn parity_text_only_run() {
4316        assert_run_stream_parity("just say hi", &[ScriptedTurn::Text("hi there")]).await;
4317    }
4318
4319    #[tokio::test]
4320    async fn parity_single_tool_then_text() {
4321        assert_run_stream_parity(
4322            "add 2 and 3",
4323            &[
4324                ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4325                ScriptedTurn::Text("the answer is 5"),
4326            ],
4327        )
4328        .await;
4329    }
4330
4331    #[tokio::test]
4332    async fn parity_multiple_tools_in_one_turn() {
4333        assert_run_stream_parity(
4334            "add two pairs",
4335            &[
4336                ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3), add_call("tc2", 10, 20)]),
4337                ScriptedTurn::Text("done"),
4338            ],
4339        )
4340        .await;
4341    }
4342
4343    #[tokio::test]
4344    async fn parity_multi_turn_sequential_tools() {
4345        assert_run_stream_parity(
4346            "chain two additions",
4347            &[
4348                ScriptedTurn::ToolCalls(vec![add_call("tc1", 1, 1)]),
4349                ScriptedTurn::ToolCalls(vec![add_call("tc2", 2, 2)]),
4350                ScriptedTurn::Text("chained"),
4351            ],
4352        )
4353        .await;
4354    }
4355
4356    /// Skips an invalid tool call (synthetic result, no execution); observes
4357    /// everything else.
4358    struct SkipInvalidHook(&'static str);
4359
4360    impl<M: CompletionModel> AgentHook<M> for SkipInvalidHook {
4361        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4362            if let StepEvent::InvalidToolCall(_) = event {
4363                Flow::skip(self.0)
4364            } else {
4365                Flow::cont()
4366            }
4367        }
4368    }
4369
4370    /// An invalid tool call *skipped* by a hook recovers identically under
4371    /// `run()` and `stream()`: the synthetic skip result enters the history
4372    /// verbatim (it is never re-parsed as tool output) and both drivers reach
4373    /// the same output and message history. Complements the repair-parity test.
4374    #[tokio::test]
4375    async fn invalid_tool_call_skip_parity_across_run_and_stream() {
4376        let blocking_model = MockCompletionModel::from_turns([
4377            MockTurn::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4378            MockTurn::text("acknowledged"),
4379        ]);
4380        let blocking_hook = RecordingHook::default();
4381        let blocking = AgentBuilder::new(blocking_model)
4382            .tool(MockAddTool)
4383            .build()
4384            .runner("do the thing")
4385            .max_turns(3)
4386            .add_hook(blocking_hook.clone())
4387            .add_hook(SkipInvalidHook("tool not permitted"))
4388            .run()
4389            .await
4390            .expect("blocking run should recover via skip");
4391
4392        // Single complete tool call (mirrors the blocking model; see the
4393        // repair-parity test for why deltas are not mixed in here).
4394        let streaming_model = MockCompletionModel::from_stream_turns([
4395            vec![
4396                MockStreamEvent::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4397                MockStreamEvent::final_response_with_total_tokens(0),
4398            ],
4399            vec![
4400                MockStreamEvent::text("acknowledged"),
4401                MockStreamEvent::final_response_with_total_tokens(0),
4402            ],
4403        ]);
4404        let streaming_hook = RecordingHook::default();
4405        let mut stream = AgentBuilder::new(streaming_model)
4406            .tool(MockAddTool)
4407            .build()
4408            .runner("do the thing")
4409            .max_turns(3)
4410            .add_hook(streaming_hook.clone())
4411            .add_hook(SkipInvalidHook("tool not permitted"))
4412            .stream()
4413            .await;
4414        let mut final_response = None;
4415        while let Some(item) = stream.next().await {
4416            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4417                item.map_err(|err| panic!("stream item errored: {err}"))
4418            {
4419                final_response = Some(resp);
4420            }
4421        }
4422        let final_response =
4423            final_response.expect("stream should recover and yield a final response");
4424
4425        assert_eq!(blocking.output, "acknowledged");
4426        assert_eq!(final_response.output(), blocking.output);
4427        assert_eq!(
4428            blocking_hook.shared_events(),
4429            streaming_hook.shared_events()
4430        );
4431        assert!(
4432            blocking_hook
4433                .shared_events()
4434                .contains(&StepEventKind::InvalidToolCall),
4435            "the hook must observe the invalid tool call"
4436        );
4437
4438        let blocking_messages = blocking.messages.expect("blocking messages");
4439        let streaming_messages = final_response
4440            .messages()
4441            .expect("streaming history")
4442            .to_vec();
4443        assert_eq!(
4444            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4445            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4446        );
4447        // Pin the actual reason, not just blocking == streaming (see the valid-tool
4448        // skip test): a reason dropped or altered on BOTH paths would still pass.
4449        assert!(
4450            tool_result_text_in_history(&blocking_messages, "tool not permitted"),
4451            "the verbatim invalid-tool skip reason must be the tool result content"
4452        );
4453    }
4454
4455    /// A turn that streams *text and* an invalid tool call, then is repaired, is
4456    /// a recovered turn: its response-finish hook must be suppressed on BOTH
4457    /// drivers — `CompletionResponse` under `run()`, `StreamResponseFinish` under
4458    /// `stream()`. The shared-events parity harness deliberately excludes these
4459    /// medium-specific events, so this asymmetry needs a dedicated assertion (it
4460    /// is the exact event the harness cannot see).
4461    #[tokio::test]
4462    async fn recovered_turn_suppresses_response_finish_hook_on_both_drivers() {
4463        // Turn 1 emits text then an invalid tool call (repaired to "add"); turn 2
4464        // is a plain final-text turn whose response event DOES fire on both
4465        // drivers — so a correct run sees exactly one response-finish event.
4466        let blocking_model = MockCompletionModel::from_turns([
4467            MockTurn::from_contents([
4468                AssistantContent::text("let me compute that"),
4469                AssistantContent::ToolCall(ToolCall::new(
4470                    "tc1".to_string(),
4471                    ToolFunction::new("default_api".to_string(), json!({"x": 2, "y": 3})),
4472                )),
4473            ])
4474            .expect("a text + tool-call turn is valid"),
4475            MockTurn::text("the answer is 5"),
4476        ]);
4477        let blocking_hook = RecordingHook::default();
4478        let blocking = AgentBuilder::new(blocking_model)
4479            .tool(MockAddTool)
4480            .build()
4481            .runner("compute")
4482            .max_turns(3)
4483            .add_hook(blocking_hook.clone())
4484            .add_hook(RepairInvalidToHook("add"))
4485            .run()
4486            .await
4487            .expect("blocking run should recover via repair");
4488
4489        let streaming_model = MockCompletionModel::from_stream_turns([
4490            vec![
4491                MockStreamEvent::text("let me compute that"),
4492                MockStreamEvent::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4493                MockStreamEvent::final_response_with_total_tokens(0),
4494            ],
4495            vec![
4496                MockStreamEvent::text("the answer is 5"),
4497                MockStreamEvent::final_response_with_total_tokens(0),
4498            ],
4499        ]);
4500        let streaming_hook = RecordingHook::default();
4501        let mut stream = AgentBuilder::new(streaming_model)
4502            .tool(MockAddTool)
4503            .build()
4504            .runner("compute")
4505            .max_turns(3)
4506            .add_hook(streaming_hook.clone())
4507            .add_hook(RepairInvalidToHook("add"))
4508            .stream()
4509            .await;
4510        while stream.next().await.is_some() {}
4511
4512        // Recovery still reaches the same final answer.
4513        assert_eq!(blocking.output, "the answer is 5");
4514
4515        // Blocking: the recovered turn 1 suppresses `CompletionResponse`; only the
4516        // plain turn 2 fires it.
4517        assert_eq!(
4518            blocking_hook.count(StepEventKind::CompletionResponse),
4519            1,
4520            "the recovered turn must not fire CompletionResponse"
4521        );
4522        // Streaming: the recovered turn 1 must likewise suppress
4523        // `StreamResponseFinish` (without the fix this is 2).
4524        assert_eq!(
4525            streaming_hook.count(StepEventKind::StreamResponseFinish),
4526            1,
4527            "the recovered turn must not fire StreamResponseFinish"
4528        );
4529        // Stated as parity: the count of un-suppressed response-finish events is
4530        // the same across drivers.
4531        assert_eq!(
4532            blocking_hook.count(StepEventKind::CompletionResponse),
4533            streaming_hook.count(StepEventKind::StreamResponseFinish),
4534        );
4535
4536        // The normalized per-turn `ModelTurnFinished` is suppressed on the
4537        // recovered turn 1 on BOTH surfaces too (its own guard, separate from the
4538        // medium-specific response-finish guards above), so only the accepted turn
4539        // 2 fires it — count is 1, not 2, on each driver. Without the suppression
4540        // this would be 2, and a per-turn accounting hook would double-count the
4541        // recovered turn.
4542        assert_eq!(
4543            blocking_hook.count(StepEventKind::ModelTurnFinished),
4544            1,
4545            "the recovered turn must not fire ModelTurnFinished"
4546        );
4547        assert_eq!(
4548            streaming_hook.count(StepEventKind::ModelTurnFinished),
4549            1,
4550            "the recovered turn must not fire ModelTurnFinished on the streaming surface either"
4551        );
4552        // Parity: the normalized per-turn event fires the same number of times on
4553        // both drivers even when a turn is recovered.
4554        assert_eq!(
4555            blocking_hook.count(StepEventKind::ModelTurnFinished),
4556            streaming_hook.count(StepEventKind::ModelTurnFinished),
4557        );
4558    }
4559
4560    /// A prompt/runner-level `add_hook` APPENDS to the agent's default hooks
4561    /// rather than replacing them (the `with_hook` → `add_hook` semantic change):
4562    /// a hook registered on the builder and a hook registered on the runner both
4563    /// observe the same run.
4564    #[tokio::test]
4565    async fn runner_add_hook_appends_to_agent_default_hooks() {
4566        let agent_hook = RecordingHook::default();
4567        let runner_hook = RecordingHook::default();
4568
4569        // `agent_hook` is registered on the builder; `runner_hook` is registered
4570        // on the runner obtained from that agent. `AgentRunner::from_agent` clones
4571        // the agent's hook stack and `add_hook` pushes on top, so both must fire.
4572        AgentBuilder::new(blocking_model())
4573            .tool(MockAddTool)
4574            .add_hook(agent_hook.clone())
4575            .build()
4576            .runner("add 2 and 3")
4577            .max_turns(3)
4578            .add_hook(runner_hook.clone())
4579            .run()
4580            .await
4581            .expect("run should succeed");
4582
4583        assert!(
4584            agent_hook.count(StepEventKind::CompletionCall) >= 1,
4585            "the agent-default hook must still observe the run after a runner-level add_hook"
4586        );
4587        assert!(
4588            runner_hook.count(StepEventKind::CompletionCall) >= 1,
4589            "the runner-level hook must also observe the run"
4590        );
4591        // Both saw the same number of completion calls — the runner-level hook
4592        // appended to the agent stack; it did not replace it.
4593        assert_eq!(
4594            agent_hook.count(StepEventKind::CompletionCall),
4595            runner_hook.count(StepEventKind::CompletionCall),
4596            "add_hook appends (both hooks observe every turn); it does not replace"
4597        );
4598    }
4599
4600    /// Skips a *valid* tool call before execution; observes everything else.
4601    struct SkipToolCallHook(&'static str);
4602
4603    impl<M: CompletionModel> AgentHook<M> for SkipToolCallHook {
4604        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4605            if let StepEvent::ToolCall { .. } = event {
4606                Flow::skip(self.0)
4607            } else {
4608                Flow::cont()
4609            }
4610        }
4611    }
4612
4613    /// A hook that skips a *valid* tool call (`Flow::Skip` on `ToolCall`, the
4614    /// honored-action path — distinct from skipping an *invalid* call) recovers
4615    /// identically under `run()` and `stream()`: the synthetic skip result enters
4616    /// the history verbatim without executing the tool, and both drivers reach the
4617    /// same output, tool-result content and message history.
4618    #[tokio::test]
4619    async fn valid_tool_call_skip_parity_across_run_and_stream() {
4620        let turns = [
4621            ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4622            ScriptedTurn::Text("acknowledged"),
4623        ];
4624
4625        let blocking_model =
4626            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4627        let blocking_hook = RecordingHook::default();
4628        let blocking = AgentBuilder::new(blocking_model)
4629            .tool(MockAddTool)
4630            .build()
4631            .runner("add 2 and 3")
4632            .max_turns(3)
4633            .add_hook(blocking_hook.clone())
4634            .add_hook(SkipToolCallHook("skipped by policy"))
4635            .run()
4636            .await
4637            .expect("blocking run should succeed with a skipped tool call");
4638
4639        let streaming_model = MockCompletionModel::from_stream_turns(
4640            turns
4641                .iter()
4642                .map(|turn| turn.as_stream_events(StreamShape::Complete)),
4643        );
4644        let streaming_hook = RecordingHook::default();
4645        let mut stream = AgentBuilder::new(streaming_model)
4646            .tool(MockAddTool)
4647            .build()
4648            .runner("add 2 and 3")
4649            .max_turns(3)
4650            .add_hook(streaming_hook.clone())
4651            .add_hook(SkipToolCallHook("skipped by policy"))
4652            .stream()
4653            .await;
4654        let mut final_response = None;
4655        while let Some(item) = stream.next().await {
4656            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4657                item.map_err(|err| panic!("stream item errored: {err}"))
4658            {
4659                final_response = Some(resp);
4660            }
4661        }
4662        let final_response = final_response.expect("stream should yield a final response");
4663
4664        assert_eq!(blocking.output, "acknowledged");
4665        assert_eq!(final_response.output(), blocking.output);
4666        assert_eq!(
4667            blocking_hook.shared_events(),
4668            streaming_hook.shared_events()
4669        );
4670        // A skipped valid tool call fires the `ToolResult` hook carrying a
4671        // structured `Skipped` outcome (the redesign surfaces the skip to result
4672        // hooks), so both drivers record the verbatim skip reason as the result.
4673        assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4674        assert_eq!(
4675            blocking_hook.tool_results(),
4676            vec!["skipped by policy".to_string()],
4677            "a skipped tool fires a ToolResult hook with the verbatim skip reason"
4678        );
4679
4680        let blocking_messages = blocking.messages.expect("blocking messages");
4681        let streaming_messages = final_response
4682            .messages()
4683            .expect("streaming history")
4684            .to_vec();
4685        assert_eq!(
4686            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4687            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4688        );
4689        // Pin the actual reason, not just blocking == streaming: a reason dropped
4690        // or altered on BOTH paths would still satisfy the equality above.
4691        assert!(
4692            tool_result_text_in_history(&blocking_messages, "skipped by policy"),
4693            "the verbatim skip reason must be the tool result content in the history"
4694        );
4695    }
4696
4697    /// A hook that rewrites a valid tool call's arguments (`Flow::RewriteArgs` on
4698    /// `ToolCall`) so the tool executes with the replacement instead of what the
4699    /// model emitted.
4700    struct RewriteToolArgsHook(serde_json::Value);
4701
4702    impl<M: CompletionModel> AgentHook<M> for RewriteToolArgsHook {
4703        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4704            if let StepEvent::ToolCall { .. } = event {
4705                Flow::rewrite_args(self.0.clone())
4706            } else {
4707                Flow::cont()
4708            }
4709        }
4710    }
4711
4712    /// `Flow::RewriteArgs` resolves to a `ProceedWith` tool-call decision that
4713    /// carries the replacement arguments, and is named for fail-closed
4714    /// diagnostics.
4715    #[test]
4716    fn rewrite_args_resolves_to_proceed_with_for_tool_call() {
4717        let args = json!({"x": 1, "y": 2});
4718        match super::flow_into_tool_call(Flow::rewrite_args(args.clone())) {
4719            super::ToolCallDecision::ProceedWith(replacement) => assert_eq!(replacement, args),
4720            _ => panic!("RewriteArgs should resolve to ProceedWith for a tool call"),
4721        }
4722        assert_eq!(
4723            super::flow_name(&Flow::rewrite_args(json!({}))),
4724            "RewriteArgs"
4725        );
4726        // The typed convenience builds the same variant as the value constructor.
4727        assert_eq!(
4728            Flow::try_rewrite_args(&json!({"x": 1, "y": 2})).expect("serializes"),
4729            Flow::rewrite_args(json!({"x": 1, "y": 2})),
4730        );
4731    }
4732
4733    /// `RewriteArgs` is only honored by `ToolCall`; every other event is
4734    /// fail-closed and terminates the run rather than silently proceeding.
4735    #[test]
4736    fn rewrite_args_is_fail_closed_off_the_tool_call_event() {
4737        // Invalid tool calls only honor Fail/Retry/Repair/Skip/Terminate.
4738        assert!(matches!(
4739            super::flow_into_invalid(Flow::rewrite_args(json!({}))),
4740            super::InvalidDecision::Terminate(_)
4741        ));
4742        // Observe-only events only honor Continue/Terminate.
4743        assert!(super::observe_flow(Flow::rewrite_args(json!({}))).is_some());
4744    }
4745
4746    /// A hook that rewrites a *valid* tool call's arguments (`Flow::RewriteArgs`
4747    /// on `ToolCall`) is honored identically under `run()` and `stream()`: the
4748    /// tool executes with the replacement, so both drivers observe the same
4749    /// rewritten tool result and reach the same output, tool-result content and
4750    /// message history. Both drivers share `run_single_tool`, so they stay in
4751    /// lock-step.
4752    #[tokio::test]
4753    async fn valid_tool_call_rewrite_args_parity_across_run_and_stream() {
4754        // The model asks to add 2 + 3; the hook rewrites the arguments to 2 + 40,
4755        // so the tool returns 42 rather than 5.
4756        let turns = [
4757            ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4758            ScriptedTurn::Text("acknowledged"),
4759        ];
4760        let replacement = json!({"x": 2, "y": 40});
4761
4762        let blocking_model =
4763            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4764        let blocking_hook = RecordingHook::default();
4765        let blocking = AgentBuilder::new(blocking_model)
4766            .tool(MockAddTool)
4767            .build()
4768            .runner("add 2 and 3")
4769            .max_turns(3)
4770            .add_hook(blocking_hook.clone())
4771            .add_hook(RewriteToolArgsHook(replacement.clone()))
4772            .run()
4773            .await
4774            .expect("blocking run should succeed with rewritten tool arguments");
4775
4776        let streaming_model = MockCompletionModel::from_stream_turns(
4777            turns
4778                .iter()
4779                .map(|turn| turn.as_stream_events(StreamShape::Complete)),
4780        );
4781        let streaming_hook = RecordingHook::default();
4782        let mut stream = AgentBuilder::new(streaming_model)
4783            .tool(MockAddTool)
4784            .build()
4785            .runner("add 2 and 3")
4786            .max_turns(3)
4787            .add_hook(streaming_hook.clone())
4788            .add_hook(RewriteToolArgsHook(replacement))
4789            .stream()
4790            .await;
4791        let mut final_response = None;
4792        while let Some(item) = stream.next().await {
4793            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4794                item.map_err(|err| panic!("stream item errored: {err}"))
4795            {
4796                final_response = Some(resp);
4797            }
4798        }
4799        let final_response = final_response.expect("stream should yield a final response");
4800
4801        // The tool ran with the rewritten arguments (2 + 40 = 42), not the
4802        // model's emitted 2 + 3 = 5 — on both drivers.
4803        assert_eq!(blocking_hook.tool_results(), vec!["42".to_string()]);
4804        assert_eq!(blocking.output, "acknowledged");
4805        assert_eq!(final_response.output(), blocking.output);
4806        assert_eq!(
4807            blocking_hook.shared_events(),
4808            streaming_hook.shared_events()
4809        );
4810        assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4811    }
4812
4813    /// A hook that rewrites a tool's result (`Flow::RewriteResult` on
4814    /// `ToolResult`) so the model sees the replacement instead of the tool's
4815    /// actual output.
4816    struct RewriteToolResultHook(&'static str);
4817
4818    impl<M: CompletionModel> AgentHook<M> for RewriteToolResultHook {
4819        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4820            if let StepEvent::ToolResult { .. } = event {
4821                Flow::rewrite_result(self.0)
4822            } else {
4823                Flow::cont()
4824            }
4825        }
4826    }
4827
4828    /// `Flow::RewriteResult` resolves to a `Replace` tool-result decision carrying
4829    /// the replacement, and is named for fail-closed diagnostics.
4830    #[test]
4831    fn rewrite_result_resolves_to_replace_for_tool_result() {
4832        match super::flow_into_tool_result(Flow::rewrite_result("redacted")) {
4833            super::ToolResultDecision::Replace(result) => assert_eq!(result, "redacted"),
4834            _ => panic!("RewriteResult should resolve to Replace for a tool result"),
4835        }
4836        assert_eq!(
4837            super::flow_name(&Flow::rewrite_result("x")),
4838            "RewriteResult"
4839        );
4840    }
4841
4842    /// `RewriteResult` is only honored by `ToolResult`, and the tool-result event
4843    /// only honors `RewriteResult` (not `RewriteArgs`) — both directions are
4844    /// fail-closed.
4845    #[test]
4846    fn rewrite_result_is_fail_closed_off_the_tool_result_event() {
4847        // Invalid tool calls don't honor RewriteResult.
4848        assert!(matches!(
4849            super::flow_into_invalid(Flow::rewrite_result("x")),
4850            super::InvalidDecision::Terminate(_)
4851        ));
4852        // Observe-only events (CompletionResponse, deltas, ...) don't honor it.
4853        assert!(super::observe_flow(Flow::rewrite_result("x")).is_some());
4854        // The tool-RESULT event rejects RewriteArgs (the pre-tool action),
4855        // mirroring how the tool-CALL event rejects RewriteResult.
4856        assert!(matches!(
4857            super::flow_into_tool_result(Flow::rewrite_args(json!({}))),
4858            super::ToolResultDecision::Terminate(_)
4859        ));
4860    }
4861
4862    /// A hook that rewrites a tool's result (`Flow::RewriteResult` on
4863    /// `ToolResult`) is honored identically under `run()` and `stream()`: the
4864    /// model-visible history carries the replacement while the `ToolResult` event
4865    /// still observed the tool's actual output, and both drivers reach the same
4866    /// output and history. Both share `run_single_tool`, so they stay in
4867    /// lock-step.
4868    #[tokio::test]
4869    async fn valid_tool_result_rewrite_parity_across_run_and_stream() {
4870        // The tool computes 2 + 3 = 5; the hook replaces what the model sees with
4871        // "redacted-result".
4872        let turns = [
4873            ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4874            ScriptedTurn::Text("acknowledged"),
4875        ];
4876
4877        let blocking_model =
4878            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4879        let blocking_hook = RecordingHook::default();
4880        let blocking = AgentBuilder::new(blocking_model)
4881            .tool(MockAddTool)
4882            .build()
4883            .runner("add 2 and 3")
4884            .max_turns(3)
4885            .add_hook(blocking_hook.clone())
4886            .add_hook(RewriteToolResultHook("redacted-result"))
4887            .run()
4888            .await
4889            .expect("blocking run should succeed with a rewritten tool result");
4890
4891        let streaming_model = MockCompletionModel::from_stream_turns(
4892            turns
4893                .iter()
4894                .map(|turn| turn.as_stream_events(StreamShape::Complete)),
4895        );
4896        let streaming_hook = RecordingHook::default();
4897        let mut stream = AgentBuilder::new(streaming_model)
4898            .tool(MockAddTool)
4899            .build()
4900            .runner("add 2 and 3")
4901            .max_turns(3)
4902            .add_hook(streaming_hook.clone())
4903            .add_hook(RewriteToolResultHook("redacted-result"))
4904            .stream()
4905            .await;
4906        let mut final_response = None;
4907        while let Some(item) = stream.next().await {
4908            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4909                item.map_err(|err| panic!("stream item errored: {err}"))
4910            {
4911                final_response = Some(resp);
4912            }
4913        }
4914        let final_response = final_response.expect("stream should yield a final response");
4915
4916        assert_eq!(blocking.output, "acknowledged");
4917        assert_eq!(final_response.output(), blocking.output);
4918
4919        // The ToolResult event observes the tool's ACTUAL output (5) on both
4920        // drivers — the replacement is applied after the event fires.
4921        assert_eq!(blocking_hook.tool_results(), vec!["5".to_string()]);
4922        assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4923
4924        // The model-visible history carries the REWRITTEN result, not "5", and is
4925        // byte-identical across drivers.
4926        let blocking_messages = blocking.messages.expect("blocking messages");
4927        let streaming_messages = final_response
4928            .messages()
4929            .expect("streaming history")
4930            .to_vec();
4931        assert_eq!(
4932            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4933            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4934        );
4935        assert!(
4936            tool_result_text_in_history(&blocking_messages, "redacted-result"),
4937            "the model-visible tool result must be the hook's replacement"
4938        );
4939        assert!(
4940            !tool_result_text_in_history(&blocking_messages, "5"),
4941            "the tool's original output must not reach the model after a rewrite"
4942        );
4943    }
4944
4945    /// A `RewriteResult` replacement is delivered to the model verbatim, not
4946    /// re-parsed as structured/multimodal tool output. A JSON-shaped replacement
4947    /// (here, an image payload that `tool_result_output` would turn into an image
4948    /// content block for *real* tool output) reaches history as literal text —
4949    /// so a redaction hook returning JSON cannot be silently restructured.
4950    #[tokio::test]
4951    async fn rewrite_result_is_delivered_verbatim_not_reparsed() {
4952        const IMAGE_JSON: &str = r#"{"type":"image","data":"abc","mimeType":"image/png"}"#;
4953
4954        let turns = [
4955            ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4956            ScriptedTurn::Text("done"),
4957        ];
4958        let model =
4959            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4960        let result = AgentBuilder::new(model)
4961            .tool(MockAddTool)
4962            .build()
4963            .runner("add 2 and 3")
4964            .max_turns(3)
4965            .add_hook(RewriteToolResultHook(IMAGE_JSON))
4966            .run()
4967            .await
4968            .expect("run should succeed with a JSON-shaped rewritten result");
4969
4970        let messages = result.messages.expect("messages");
4971        assert!(
4972            tool_result_text_in_history(&messages, IMAGE_JSON),
4973            "the JSON-shaped replacement must reach history verbatim as text, not be \
4974             re-parsed into a structured/image content block"
4975        );
4976    }
4977
4978    /// A hook that patches the model request for the turn (`Flow::PatchRequest`
4979    /// on `CompletionCall`): forces tool_choice + temperature, narrows the
4980    /// advertised tools to an allow-list, and injects a passthrough param.
4981    struct PatchRequestHook;
4982
4983    impl<M: CompletionModel> AgentHook<M> for PatchRequestHook {
4984        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4985            if let StepEvent::CompletionCall { .. } = event {
4986                Flow::patch_request(
4987                    RequestPatch::new()
4988                        .preamble(OVERRIDE_PREAMBLE)
4989                        .temperature(0.25)
4990                        .max_tokens(OVERRIDE_MAX_TOKENS)
4991                        .tool_choice(ToolChoice::Required)
4992                        .active_tools(["add"])
4993                        .additional_params(json!({"injected": true})),
4994                )
4995            } else {
4996                Flow::cont()
4997            }
4998        }
4999    }
5000
5001    const OVERRIDE_PREAMBLE: &str = "overridden: critical-step instructions";
5002    const OVERRIDE_MAX_TOKENS: u64 = 512;
5003
5004    /// `Flow::PatchRequest` resolves to a `Patch` completion-call decision
5005    /// carrying the patch, and is named for fail-closed diagnostics.
5006    #[test]
5007    fn patch_request_resolves_to_patch_for_completion_call() {
5008        let patch = RequestPatch::new()
5009            .temperature(0.25)
5010            .tool_choice(ToolChoice::Required);
5011        match super::flow_into_completion_call(Flow::patch_request(patch.clone())) {
5012            super::CompletionCallDecision::Patch(got) => assert_eq!(got, patch),
5013            _ => panic!("PatchRequest should resolve to Patch for a completion call"),
5014        }
5015        assert_eq!(
5016            super::flow_name(&Flow::patch_request(RequestPatch::new())),
5017            "PatchRequest"
5018        );
5019    }
5020
5021    /// `PatchRequest` is only honored by `CompletionCall`; every other event is
5022    /// fail-closed, and `CompletionCall` only honors Continue/PatchRequest/Terminate.
5023    #[test]
5024    fn patch_request_is_fail_closed_off_the_completion_call_event() {
5025        let patch = || Flow::patch_request(RequestPatch::new());
5026        assert!(matches!(
5027            super::flow_into_invalid(patch()),
5028            super::InvalidDecision::Terminate(_)
5029        ));
5030        assert!(matches!(
5031            super::flow_into_tool_call(patch()),
5032            super::ToolCallDecision::Terminate(_)
5033        ));
5034        assert!(matches!(
5035            super::flow_into_tool_result(patch()),
5036            super::ToolResultDecision::Terminate(_)
5037        ));
5038        assert!(super::observe_flow(patch()).is_some());
5039        // The completion-call event rejects an action it can't honor (e.g. Skip).
5040        assert!(matches!(
5041            super::flow_into_completion_call(Flow::skip("x")),
5042            super::CompletionCallDecision::Terminate(_)
5043        ));
5044    }
5045
5046    /// A `Flow::PatchRequest` hook patches the request for the turn identically
5047    /// under `run()` and `stream()`: the captured completion request shows the
5048    /// overridden temperature/tool_choice, the merged additional_params, and the
5049    /// tool set narrowed to the allow-list — on both drivers.
5050    #[tokio::test]
5051    async fn patch_request_parity_across_run_and_stream() {
5052        fn assert_request(req: &crate::completion::CompletionRequest) {
5053            assert_eq!(
5054                req.temperature,
5055                Some(0.25),
5056                "override temperature wins over the agent's 0.9"
5057            );
5058            assert_eq!(
5059                req.max_tokens,
5060                Some(OVERRIDE_MAX_TOKENS),
5061                "override max_tokens wins over the agent's 64"
5062            );
5063            // The override preamble wins and is sent as the leading system message.
5064            let system = req.chat_history.iter().find_map(|m| match m {
5065                Message::System { content } => Some(content.as_str()),
5066                _ => None,
5067            });
5068            assert_eq!(
5069                system,
5070                Some(OVERRIDE_PREAMBLE),
5071                "override preamble wins over the agent's baseline and is the leading system message"
5072            );
5073            assert!(matches!(req.tool_choice, Some(ToolChoice::Required)));
5074            let tool_names: Vec<&str> = req.tools.iter().map(|t| t.name.as_str()).collect();
5075            assert_eq!(
5076                tool_names,
5077                ["add"],
5078                "active_tools narrows the advertised set to `add` (drops `subtract`)"
5079            );
5080            // additional_params is shallow-merged: the agent baseline survives and
5081            // the override's key is added.
5082            let params = req.additional_params.as_ref().expect("additional_params");
5083            assert_eq!(
5084                params.get("baseline").and_then(|v| v.as_str()),
5085                Some("keep")
5086            );
5087            assert_eq!(params.get("injected").and_then(|v| v.as_bool()), Some(true));
5088        }
5089
5090        let blocking_model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5091        let blocking_probe = blocking_model.clone();
5092        let blocking = AgentBuilder::new(blocking_model)
5093            .tool(MockAddTool)
5094            .tool(MockSubtractTool)
5095            .preamble("baseline preamble")
5096            .temperature(0.9)
5097            .max_tokens(64)
5098            .additional_params(json!({"baseline": "keep"}))
5099            .add_hook(PatchRequestHook)
5100            .build()
5101            .runner("go")
5102            .max_turns(2)
5103            .run()
5104            .await
5105            .expect("blocking run should succeed");
5106        assert_eq!(blocking.output, "done");
5107        let blocking_requests = blocking_probe.requests();
5108        assert_eq!(blocking_requests.len(), 1);
5109        assert_request(&blocking_requests[0]);
5110
5111        let streaming_model = MockCompletionModel::from_stream_turns([
5112            ScriptedTurn::Text("done").as_stream_events(StreamShape::Complete)
5113        ]);
5114        let streaming_probe = streaming_model.clone();
5115        let mut stream = AgentBuilder::new(streaming_model)
5116            .tool(MockAddTool)
5117            .tool(MockSubtractTool)
5118            .preamble("baseline preamble")
5119            .temperature(0.9)
5120            .max_tokens(64)
5121            .additional_params(json!({"baseline": "keep"}))
5122            .add_hook(PatchRequestHook)
5123            .build()
5124            .runner("go")
5125            .max_turns(2)
5126            .stream()
5127            .await;
5128        while let Some(item) = stream.next().await {
5129            let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5130        }
5131        let streaming_requests = streaming_probe.requests();
5132        assert_eq!(streaming_requests.len(), 1);
5133        assert_request(&streaming_requests[0]);
5134    }
5135
5136    // --- Hook system v2: extra_context, history view, ModelTurnFinished, chained rewrites ---
5137
5138    fn hook_doc(id: &str, text: &str) -> crate::completion::Document {
5139        crate::completion::Document {
5140            id: id.to_string(),
5141            text: text.to_string(),
5142            additional_props: Default::default(),
5143        }
5144    }
5145
5146    /// Injects one extra context document on every completion call.
5147    struct ExtraContextHook {
5148        id: &'static str,
5149        text: &'static str,
5150    }
5151
5152    impl<M: CompletionModel> AgentHook<M> for ExtraContextHook {
5153        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5154            if let StepEvent::CompletionCall { .. } = event {
5155                Flow::patch_request(RequestPatch::new().context(hook_doc(self.id, self.text)))
5156            } else {
5157                Flow::cont()
5158            }
5159        }
5160    }
5161
5162    /// Injects an extra context document only on the first turn (to prove
5163    /// per-turn, non-sticky behavior).
5164    struct ExtraContextTurnOneHook;
5165
5166    impl<M: CompletionModel> AgentHook<M> for ExtraContextTurnOneHook {
5167        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5168            if let StepEvent::CompletionCall { turn, .. } = event
5169                && turn == 1
5170            {
5171                return Flow::patch_request(
5172                    RequestPatch::new().context(hook_doc("turn-one", "only turn 1")),
5173                );
5174            }
5175            Flow::cont()
5176        }
5177    }
5178
5179    fn one_text_stream_turn(text: &'static str) -> Vec<MockStreamEvent> {
5180        vec![
5181            MockStreamEvent::text(text),
5182            MockStreamEvent::final_response_with_total_tokens(0),
5183        ]
5184    }
5185
5186    /// A single hook's `extra_context` document appears in the completion request,
5187    /// after the agent's static context, on both `run()` and `stream()`.
5188    #[tokio::test]
5189    async fn extra_context_appears_after_static_context_on_both_surfaces() {
5190        fn assert_docs(req: &crate::completion::CompletionRequest) {
5191            let ids: Vec<&str> = req.documents.iter().map(|d| d.id.as_str()).collect();
5192            let static_pos = ids
5193                .iter()
5194                .position(|id| id.starts_with("static_doc"))
5195                .expect("static context document present");
5196            let extra_pos = ids
5197                .iter()
5198                .position(|id| *id == "hook-doc")
5199                .expect("hook extra_context document present");
5200            assert!(
5201                static_pos < extra_pos,
5202                "static context precedes hook extras: {ids:?}"
5203            );
5204            assert!(
5205                req.documents.iter().any(|d| d.text == "injected"),
5206                "the hook document's text is present"
5207            );
5208        }
5209
5210        let blocking_model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5211        let blocking_probe = blocking_model.clone();
5212        AgentBuilder::new(blocking_model)
5213            .context("static context text")
5214            .add_hook(ExtraContextHook {
5215                id: "hook-doc",
5216                text: "injected",
5217            })
5218            .build()
5219            .runner("go")
5220            .run()
5221            .await
5222            .expect("blocking run should succeed");
5223        assert_docs(blocking_probe.requests().first().expect("one request"));
5224
5225        let streaming_model =
5226            MockCompletionModel::from_stream_turns([one_text_stream_turn("done")]);
5227        let streaming_probe = streaming_model.clone();
5228        let mut stream = AgentBuilder::new(streaming_model)
5229            .context("static context text")
5230            .add_hook(ExtraContextHook {
5231                id: "hook-doc",
5232                text: "injected",
5233            })
5234            .build()
5235            .runner("go")
5236            .stream()
5237            .await;
5238        while let Some(item) = stream.next().await {
5239            let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5240        }
5241        assert_docs(streaming_probe.requests().first().expect("one request"));
5242    }
5243
5244    /// Two hooks' `extra_context` documents append in registration order.
5245    #[tokio::test]
5246    async fn multiple_hooks_extra_context_append_in_registration_order() {
5247        let model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5248        let probe = model.clone();
5249        AgentBuilder::new(model)
5250            .add_hook(ExtraContextHook {
5251                id: "first",
5252                text: "1",
5253            })
5254            .add_hook(ExtraContextHook {
5255                id: "second",
5256                text: "2",
5257            })
5258            .build()
5259            .runner("go")
5260            .run()
5261            .await
5262            .expect("run should succeed");
5263        let requests = probe.requests();
5264        let req = requests.first().expect("one request");
5265        let ids: Vec<&str> = req.documents.iter().map(|d| d.id.as_str()).collect();
5266        assert_eq!(
5267            ids,
5268            vec!["first", "second"],
5269            "hook extras append in registration order"
5270        );
5271    }
5272
5273    /// A hook's `extra_context` is per-turn and non-sticky: a document injected on
5274    /// turn 1 does not reappear on turn 2. Checked on both surfaces.
5275    #[tokio::test]
5276    async fn extra_context_is_per_turn_non_sticky() {
5277        fn assert_turns(requests: &[crate::completion::CompletionRequest]) {
5278            assert_eq!(requests.len(), 2, "two model turns");
5279            let turn1 = requests.first().expect("turn 1");
5280            let turn2 = requests.get(1).expect("turn 2");
5281            assert!(
5282                turn1.documents.iter().any(|d| d.id == "turn-one"),
5283                "turn 1 carries the injected document"
5284            );
5285            assert!(
5286                turn2.documents.iter().all(|d| d.id != "turn-one"),
5287                "turn 2 does not inherit turn 1's per-turn document"
5288            );
5289        }
5290
5291        let blocking_probe = blocking_model();
5292        let probe = blocking_probe.clone();
5293        AgentBuilder::new(blocking_probe)
5294            .tool(MockAddTool)
5295            .add_hook(ExtraContextTurnOneHook)
5296            .build()
5297            .runner("add 2 and 3")
5298            .max_turns(3)
5299            .run()
5300            .await
5301            .expect("blocking run should succeed");
5302        assert_turns(&probe.requests());
5303
5304        let streaming = streaming_model();
5305        let stream_probe = streaming.clone();
5306        let mut stream = AgentBuilder::new(streaming)
5307            .tool(MockAddTool)
5308            .add_hook(ExtraContextTurnOneHook)
5309            .build()
5310            .runner("add 2 and 3")
5311            .max_turns(3)
5312            .stream()
5313            .await;
5314        while let Some(item) = stream.next().await {
5315            let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5316        }
5317        assert_turns(&stream_probe.requests());
5318    }
5319
5320    /// A hook that overrides `history` changes the messages sent to the provider
5321    /// for the turn without touching the persisted transcript, on both surfaces.
5322    #[tokio::test]
5323    async fn history_patch_changes_sent_messages_not_transcript_on_both_surfaces() {
5324        const SENTINEL: &str = "COMPACTED-HISTORY-SENTINEL";
5325
5326        struct HistoryOverrideHook;
5327        impl<M: CompletionModel> AgentHook<M> for HistoryOverrideHook {
5328            async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5329                if let StepEvent::CompletionCall { .. } = event {
5330                    Flow::patch_request(RequestPatch::new().history([Message::user(SENTINEL)]))
5331                } else {
5332                    Flow::cont()
5333                }
5334            }
5335        }
5336
5337        fn request_has_sentinel(req: &crate::completion::CompletionRequest) -> bool {
5338            req.chat_history.iter().any(|m| match m {
5339                Message::User { content } => content
5340                    .iter()
5341                    .any(|c| matches!(c, UserContent::Text(text) if text.text.contains(SENTINEL))),
5342                _ => false,
5343            })
5344        }
5345
5346        fn messages_have_sentinel(messages: &[Message]) -> bool {
5347            messages.iter().any(|m| match m {
5348                Message::User { content } => content
5349                    .iter()
5350                    .any(|c| matches!(c, UserContent::Text(text) if text.text.contains(SENTINEL))),
5351                _ => false,
5352            })
5353        }
5354
5355        let blocking_model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5356        let blocking_probe = blocking_model.clone();
5357        let blocking = AgentBuilder::new(blocking_model)
5358            .add_hook(HistoryOverrideHook)
5359            .build()
5360            .runner("real prompt")
5361            .run()
5362            .await
5363            .expect("blocking run should succeed");
5364        assert!(
5365            request_has_sentinel(blocking_probe.requests().first().expect("one request")),
5366            "the overridden history reaches the provider"
5367        );
5368        assert!(
5369            !messages_have_sentinel(blocking.messages.as_deref().unwrap_or_default()),
5370            "the persisted transcript is untouched by the per-turn history override"
5371        );
5372
5373        let streaming_model =
5374            MockCompletionModel::from_stream_turns([one_text_stream_turn("done")]);
5375        let streaming_probe = streaming_model.clone();
5376        let stream = AgentBuilder::new(streaming_model)
5377            .add_hook(HistoryOverrideHook)
5378            .build()
5379            .runner("real prompt")
5380            .stream()
5381            .await;
5382        let final_response = drive_to_final_response(stream).await;
5383        assert!(
5384            request_has_sentinel(streaming_probe.requests().first().expect("one request")),
5385            "the overridden history reaches the provider on the streaming surface too"
5386        );
5387        assert!(
5388            !messages_have_sentinel(final_response.messages().expect("history")),
5389            "the persisted transcript is untouched by the per-turn history override on \
5390             the streaming surface too"
5391        );
5392    }
5393
5394    /// `ModelTurnFinished` fires exactly once per accepted turn on both surfaces,
5395    /// including a streamed tool-only turn that fires no `StreamResponseFinish`.
5396    #[tokio::test]
5397    async fn model_turn_finished_fires_once_per_accepted_turn_including_tool_only() {
5398        let blocking_hook = RecordingHook::default();
5399        AgentBuilder::new(blocking_model())
5400            .tool(MockAddTool)
5401            .add_hook(blocking_hook.clone())
5402            .build()
5403            .runner("add 2 and 3")
5404            .max_turns(3)
5405            .run()
5406            .await
5407            .expect("blocking run should succeed");
5408        assert_eq!(
5409            blocking_hook.count(StepEventKind::ModelTurnFinished),
5410            2,
5411            "one ModelTurnFinished per accepted turn (tool turn + text turn)"
5412        );
5413
5414        let streaming_hook = RecordingHook::default();
5415        let mut stream = AgentBuilder::new(streaming_model())
5416            .tool(MockAddTool)
5417            .add_hook(streaming_hook.clone())
5418            .build()
5419            .runner("add 2 and 3")
5420            .max_turns(3)
5421            .stream()
5422            .await;
5423        while let Some(item) = stream.next().await {
5424            let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5425        }
5426        assert_eq!(
5427            streaming_hook.count(StepEventKind::ModelTurnFinished),
5428            2,
5429            "ModelTurnFinished fires once per turn on the streaming surface too"
5430        );
5431        // The tool-only first turn streams no assistant text, so only the second
5432        // (text) turn fires StreamResponseFinish — proving ModelTurnFinished
5433        // covers the gap.
5434        assert_eq!(
5435            streaming_hook.count(StepEventKind::StreamResponseFinish),
5436            1,
5437            "the tool-only turn fires no StreamResponseFinish"
5438        );
5439    }
5440
5441    /// Records the content kinds of the first turn's `ModelTurnFinished`.
5442    #[derive(Clone, Default)]
5443    struct CaptureFirstTurnContent {
5444        kinds: Arc<Mutex<Option<Vec<&'static str>>>>,
5445    }
5446
5447    impl<M: CompletionModel> AgentHook<M> for CaptureFirstTurnContent {
5448        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5449            if let StepEvent::ModelTurnFinished { turn, content, .. } = event
5450                && turn == 1
5451            {
5452                let kinds = content
5453                    .iter()
5454                    .map(|c| match c {
5455                        AssistantContent::Reasoning(_) => "reasoning",
5456                        AssistantContent::Text(_) => "text",
5457                        AssistantContent::ToolCall(_) => "tool_call",
5458                        _ => "other",
5459                    })
5460                    .collect();
5461                *self.kinds.lock().expect("kinds") = Some(kinds);
5462            }
5463            Flow::cont()
5464        }
5465    }
5466
5467    /// On the streaming surface, `ModelTurnFinished.content` carries the
5468    /// **canonical** committed content from `StreamedTurn::finish` (reasoning →
5469    /// text → tool calls), not the raw `stream.choice` aggregate. The turn streams
5470    /// reasoning, then a tool call, then text (a non-canonical emission order), so
5471    /// a raw-choice implementation would surface `reasoning, tool_call, text` —
5472    /// the canonical event instead reports `reasoning, text, tool_call`.
5473    #[tokio::test]
5474    async fn streaming_model_turn_finished_carries_canonical_committed_content() {
5475        let model = MockCompletionModel::from_stream_turns([
5476            vec![
5477                MockStreamEvent::reasoning("think"),
5478                MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
5479                MockStreamEvent::text("answer"),
5480                MockStreamEvent::final_response_with_total_tokens(0),
5481            ],
5482            vec![
5483                MockStreamEvent::text("done"),
5484                MockStreamEvent::final_response_with_total_tokens(0),
5485            ],
5486        ]);
5487        let hook = CaptureFirstTurnContent::default();
5488        let stream = AgentBuilder::new(model)
5489            .tool(MockAddTool)
5490            .add_hook(hook.clone())
5491            .build()
5492            .runner("go")
5493            .max_turns(3)
5494            .stream()
5495            .await;
5496        let _ = drive_to_final_response(stream).await;
5497
5498        assert_eq!(
5499            hook.kinds.lock().expect("kinds").clone(),
5500            Some(vec!["reasoning", "text", "tool_call"]),
5501            "ModelTurnFinished carries the canonical reasoning->text->tool ordering \
5502             from StreamedTurn::finish, not the raw stream.choice emission order"
5503        );
5504    }
5505
5506    /// `RewriteArgs` and `RewriteResult` chain across hooks: a later hook observes
5507    /// (and further rewrites) the value produced by earlier hooks.
5508    #[tokio::test]
5509    async fn chained_rewrites_compose_across_hooks() {
5510        /// Sets one key of the tool arguments, preserving the rest.
5511        struct SetArg {
5512            key: &'static str,
5513            value: i64,
5514        }
5515        impl<M: CompletionModel> AgentHook<M> for SetArg {
5516            async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5517                if let StepEvent::ToolCall { args, .. } = event {
5518                    let mut parsed: serde_json::Value =
5519                        serde_json::from_str(args).unwrap_or_else(|_| json!({}));
5520                    parsed[self.key] = json!(self.value);
5521                    Flow::rewrite_args(parsed)
5522                } else {
5523                    Flow::cont()
5524                }
5525            }
5526        }
5527
5528        /// Wraps the tool result in `label(...)`.
5529        struct WrapResult(&'static str);
5530        impl<M: CompletionModel> AgentHook<M> for WrapResult {
5531            async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5532                if let StepEvent::ToolResult { result, .. } = event {
5533                    Flow::rewrite_result(format!("{}({})", self.0, result))
5534                } else {
5535                    Flow::cont()
5536                }
5537            }
5538        }
5539
5540        // The model asks add(2, 3). SetArg{y:40} then SetArg{x:100} chain, so the
5541        // tool runs with (100, 40) = 140 — proving arg rewrites compose. Then
5542        // WrapResult "A" and "B" chain, and a trailing recorder observes the fully
5543        // chained result "B(A(140))".
5544        let recorder = RecordingHook::default();
5545        let blocking = AgentBuilder::new(blocking_model())
5546            .tool(MockAddTool)
5547            .add_hook(SetArg {
5548                key: "y",
5549                value: 40,
5550            })
5551            .add_hook(SetArg {
5552                key: "x",
5553                value: 100,
5554            })
5555            .add_hook(WrapResult("A"))
5556            .add_hook(WrapResult("B"))
5557            .add_hook(recorder.clone())
5558            .build()
5559            .runner("add 2 and 3")
5560            .max_turns(3)
5561            .run()
5562            .await
5563            .expect("blocking run should succeed");
5564        assert_eq!(blocking.output, "the answer is 5");
5565        assert_eq!(
5566            recorder.tool_results(),
5567            vec!["B(A(140))".to_string()],
5568            "arg rewrites compose (100+40=140) and result rewrites nest B(A(...))"
5569        );
5570
5571        // Same on the streaming surface.
5572        let stream_recorder = RecordingHook::default();
5573        let mut stream = AgentBuilder::new(streaming_model())
5574            .tool(MockAddTool)
5575            .add_hook(SetArg {
5576                key: "y",
5577                value: 40,
5578            })
5579            .add_hook(SetArg {
5580                key: "x",
5581                value: 100,
5582            })
5583            .add_hook(WrapResult("A"))
5584            .add_hook(WrapResult("B"))
5585            .add_hook(stream_recorder.clone())
5586            .build()
5587            .runner("add 2 and 3")
5588            .max_turns(3)
5589            .stream()
5590            .await;
5591        while let Some(item) = stream.next().await {
5592            let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5593        }
5594        assert_eq!(
5595            stream_recorder.tool_results(),
5596            vec!["B(A(140))".to_string()],
5597            "chained rewrites compose identically on the streaming surface"
5598        );
5599    }
5600
5601    #[derive(serde::Deserialize, schemars::JsonSchema)]
5602    #[allow(dead_code)]
5603    struct Answer {
5604        answer: String,
5605    }
5606
5607    /// A real tool whose name equals the default synthetic output-tool name
5608    /// (`final_result`). Used to prove a per-turn `active_tools` filter cannot
5609    /// make the picked output-tool name collide with it.
5610    struct FinalResultTool;
5611
5612    impl Tool for FinalResultTool {
5613        const NAME: &'static str = "final_result";
5614        type Error = MockToolError;
5615        type Args = serde_json::Value;
5616        type Output = String;
5617
5618        fn description(&self) -> String {
5619            "A real tool sharing the default output-tool name".to_string()
5620        }
5621
5622        fn parameters(&self) -> serde_json::Value {
5623            json!({ "type": "object", "properties": {} })
5624        }
5625
5626        async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
5627            Ok("real final_result output".to_string())
5628        }
5629    }
5630
5631    /// Narrows the advertised tools to `add` for the turn, filtering out the real
5632    /// `final_result` tool.
5633    struct ActiveToolsAddOnly;
5634
5635    impl<M: CompletionModel> AgentHook<M> for ActiveToolsAddOnly {
5636        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5637            if let StepEvent::CompletionCall { .. } = event {
5638                Flow::patch_request(RequestPatch::new().active_tools(["add"]))
5639            } else {
5640                Flow::cont()
5641            }
5642        }
5643    }
5644
5645    /// Regression guard: a per-turn `active_tools` allow-list that filters out a
5646    /// real tool whose name equals the default synthetic output-tool name must not
5647    /// let the picked output-tool name collide with that (filtered) real tool. The
5648    /// name is pinned for the whole run, so picking it against the FULL advertised
5649    /// set — not just this turn's narrowed executable set — keeps it collision-safe
5650    /// once the filter lifts on a later turn. With the bug, the output tool would
5651    /// be named `final_result` (picked against the narrowed `{add}`), colliding
5652    /// with the real `final_result` whenever the filter is gone.
5653    #[tokio::test]
5654    async fn active_tools_filter_does_not_let_output_tool_collide_with_a_filtered_real_tool() {
5655        // The model finalizes by calling the (correctly-picked) output tool, so a
5656        // run on the fixed code completes cleanly in a single turn. Asserting the
5657        // run succeeds also exercises finalization: the model's call to
5658        // `final_result_1` must be intercepted as the output tool, so this fails if
5659        // the picked name and the intercept name ever drift apart.
5660        let model = MockCompletionModel::from_turns([MockTurn::tool_call(
5661            "out1",
5662            "final_result_1",
5663            json!({ "answer": "done" }),
5664        )]);
5665        let probe = model.clone();
5666        let response = AgentBuilder::new(model)
5667            .tool(MockAddTool)
5668            .tool(FinalResultTool)
5669            .output_schema::<Answer>()
5670            .output_mode(OutputMode::Tool)
5671            .add_hook(ActiveToolsAddOnly)
5672            .build()
5673            .runner("go")
5674            .max_turns(2)
5675            .run()
5676            .await
5677            .expect("run should finalize via the picked output tool `final_result_1`");
5678        assert!(
5679            response.output.contains("done"),
5680            "the intercepted output-tool call should produce the structured result, \
5681             got {:?}",
5682            response.output
5683        );
5684
5685        let requests = probe.requests();
5686        assert!(
5687            !requests.is_empty(),
5688            "the first model request should be captured"
5689        );
5690        let tool_names: Vec<&str> = requests[0].tools.iter().map(|t| t.name.as_str()).collect();
5691        assert!(
5692            tool_names.contains(&"add"),
5693            "active_tools keeps `add` advertised, saw {tool_names:?}"
5694        );
5695        assert!(
5696            tool_names.contains(&"final_result_1"),
5697            "the synthetic output tool must avoid the filtered real `final_result` name, \
5698             saw {tool_names:?}"
5699        );
5700        assert!(
5701            !tool_names.contains(&"final_result"),
5702            "the real `final_result` is filtered out and the output tool must not reuse \
5703             its name, saw {tool_names:?}"
5704        );
5705    }
5706
5707    /// Captures whether any `ModelTurnFinished.content` carried a tool call named
5708    /// `final_result` — the model-emitted structured-output output-tool call.
5709    #[derive(Clone, Default)]
5710    struct CaptureOutputToolInModelTurn {
5711        saw_output_tool_call: Arc<Mutex<bool>>,
5712    }
5713
5714    impl<M: CompletionModel> AgentHook<M> for CaptureOutputToolInModelTurn {
5715        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5716            if let StepEvent::ModelTurnFinished { content, .. } = event
5717                && content.iter().any(|c| {
5718                    matches!(c, AssistantContent::ToolCall(tc) if tc.function.name == "final_result")
5719                })
5720            {
5721                *self.saw_output_tool_call.lock().expect("lock") = true;
5722            }
5723            Flow::cont()
5724        }
5725    }
5726
5727    /// `ModelTurnFinished.content` carries the **model-emitted** content — including
5728    /// a structured-output Tool-mode output-tool call — on both surfaces, even though
5729    /// the run persists that turn as assistant text (the structured output) with the
5730    /// tool call dropped. Guards the documented `content` contract: it is the model's
5731    /// committed turn content, not the finalized/persisted content, in Tool mode.
5732    #[tokio::test]
5733    async fn model_turn_finished_content_carries_output_tool_call_in_tool_mode() {
5734        // Blocking surface.
5735        let hook = CaptureOutputToolInModelTurn::default();
5736        let response = AgentBuilder::new(MockCompletionModel::from_turns([MockTurn::tool_call(
5737            "out1",
5738            "final_result",
5739            json!({ "answer": "done" }),
5740        )]))
5741        .output_schema::<Answer>()
5742        .output_mode(OutputMode::Tool)
5743        .add_hook(hook.clone())
5744        .build()
5745        .runner("go")
5746        .max_turns(2)
5747        .run()
5748        .await
5749        .expect("run should finalize via the output tool");
5750        assert!(
5751            *hook.saw_output_tool_call.lock().expect("lock"),
5752            "ModelTurnFinished.content must carry the model-emitted output-tool call (blocking)"
5753        );
5754        assert!(
5755            response.output.contains("done"),
5756            "the run finalizes with the structured output, not the raw tool call: {:?}",
5757            response.output
5758        );
5759
5760        // Streaming surface — same content contract.
5761        let s_hook = CaptureOutputToolInModelTurn::default();
5762        let mut stream = AgentBuilder::new(MockCompletionModel::from_stream_turns([vec![
5763            MockStreamEvent::tool_call("out1", "final_result", json!({ "answer": "done" })),
5764            MockStreamEvent::final_response_with_total_tokens(0),
5765        ]]))
5766        .output_schema::<Answer>()
5767        .output_mode(OutputMode::Tool)
5768        .add_hook(s_hook.clone())
5769        .build()
5770        .runner("go")
5771        .max_turns(2)
5772        .stream()
5773        .await;
5774        while stream.next().await.is_some() {}
5775        assert!(
5776            *s_hook.saw_output_tool_call.lock().expect("lock"),
5777            "ModelTurnFinished.content must carry the model-emitted output-tool call (streaming)"
5778        );
5779    }
5780
5781    /// A structured-output Tool-mode output-tool call finalizes the run directly, so
5782    /// on the streaming surface it is **not** re-emitted as a complete
5783    /// `StreamAssistantItem(StreamedAssistantContent::ToolCall)` item (it bypasses
5784    /// `drive_tool_calls`); its structured result is surfaced in the final `PromptResponse`.
5785    /// Guards the narrowed `StreamAssistantItem` contract.
5786    #[tokio::test]
5787    async fn output_tool_finalization_emits_no_complete_tool_call_stream_item() {
5788        let mut stream = AgentBuilder::new(MockCompletionModel::from_stream_turns([vec![
5789            MockStreamEvent::tool_call("out1", "final_result", json!({ "answer": "done" })),
5790            MockStreamEvent::final_response_with_total_tokens(0),
5791        ]]))
5792        .output_schema::<Answer>()
5793        .output_mode(OutputMode::Tool)
5794        .build()
5795        .runner("go")
5796        .max_turns(2)
5797        .stream()
5798        .await;
5799
5800        let mut saw_complete_output_tool_call = false;
5801        let mut final_has_output = false;
5802        while let Some(item) = stream.next().await {
5803            match item.expect("stream item") {
5804                MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall {
5805                    tool_call,
5806                    ..
5807                }) if tool_call.function.name == "final_result" => {
5808                    saw_complete_output_tool_call = true;
5809                }
5810                MultiTurnStreamItem::FinalResponse(res) => {
5811                    final_has_output = res.output().contains("done");
5812                }
5813                _ => {}
5814            }
5815        }
5816        assert!(
5817            !saw_complete_output_tool_call,
5818            "the output-tool call finalizes the run, so no complete \
5819             StreamAssistantItem::ToolCall item must be emitted for it"
5820        );
5821        assert!(
5822            final_has_output,
5823            "the structured output must be surfaced via the FinalResponse"
5824        );
5825    }
5826
5827    // -----------------------------------------------------------------------
5828    // Human-in-the-loop (HITL): one hook gates each tool call behind a human
5829    // decision, mapping approve/deny/edit/abort onto the existing Flow actions
5830    // (cont / skip / rewrite_args / terminate). The runnable interactive
5831    // version lives in `examples/agent_with_human_in_the_loop`.
5832    // -----------------------------------------------------------------------
5833
5834    /// A human reviewer's decision for a pending tool call.
5835    enum Decision {
5836        /// Run the tool as the model requested.
5837        Approve,
5838        /// Don't run the tool; feed `reason` back to the model as the result.
5839        Deny(&'static str),
5840        /// Run the tool with these arguments instead of the model's.
5841        Edit(serde_json::Value),
5842        /// Abort the whole run with this reason.
5843        Abort(&'static str),
5844    }
5845
5846    /// Simulates a human reviewer by popping a scripted decision per `ToolCall`
5847    /// and mapping it to the matching `Flow`. A real reviewer would `.await`
5848    /// interactive input here (the hook is async) rather than read a queue.
5849    #[derive(Clone)]
5850    struct HumanApprovalHook {
5851        decisions: Arc<Mutex<std::collections::VecDeque<Decision>>>,
5852        reviewed: Arc<Mutex<Vec<String>>>,
5853    }
5854
5855    impl HumanApprovalHook {
5856        fn new(decisions: impl IntoIterator<Item = Decision>) -> Self {
5857            Self {
5858                decisions: Arc::new(Mutex::new(decisions.into_iter().collect())),
5859                reviewed: Arc::new(Mutex::new(Vec::new())),
5860            }
5861        }
5862
5863        /// `"name(args)"` for each call presented for review, in order.
5864        fn reviewed(&self) -> Vec<String> {
5865            self.reviewed.lock().unwrap().clone()
5866        }
5867    }
5868
5869    impl<M: CompletionModel> AgentHook<M> for HumanApprovalHook {
5870        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5871            let StepEvent::ToolCall {
5872                tool_name, args, ..
5873            } = event
5874            else {
5875                return Flow::cont();
5876            };
5877            self.reviewed
5878                .lock()
5879                .unwrap()
5880                .push(format!("{tool_name}({args})"));
5881            let decision = self.decisions.lock().unwrap().pop_front();
5882            match decision {
5883                Some(Decision::Approve) => Flow::cont(),
5884                Some(Decision::Deny(reason)) => Flow::skip(reason),
5885                Some(Decision::Edit(args)) => Flow::rewrite_args(args),
5886                Some(Decision::Abort(reason)) => Flow::terminate(reason),
5887                // Fail closed if the script is exhausted (it shouldn't be) — deny
5888                // rather than silently approve, matching the example's contract.
5889                None => Flow::skip("denied: no scripted decision (fail-closed)"),
5890            }
5891        }
5892    }
5893
5894    /// A HITL hook that approves the first tool call, denies the second, and
5895    /// edits the third's arguments behaves identically under `run()` and
5896    /// `stream()`: approved/edited tools execute (and the edit takes effect),
5897    /// the denied tool runs nothing while its reason reaches the model, and the
5898    /// model-visible history is identical across drivers (compared structurally).
5899    #[tokio::test]
5900    async fn human_in_the_loop_approve_deny_edit_parity_across_run_and_stream() {
5901        // One turn issues three tool calls; the reviewer decides each differently.
5902        let turns = [
5903            ScriptedTurn::ToolCalls(vec![
5904                add_call("tc1", 2, 3),   // approve -> runs, 2 + 3 = 5
5905                add_call("tc2", 10, 20), // deny    -> skipped; model sees the reason
5906                add_call("tc3", 1, 1),   // edit    -> runs 1 + 100 = 101, not 1 + 1 = 2
5907            ]),
5908            ScriptedTurn::Text("done"),
5909        ];
5910        let denial = "denied by reviewer: amount too large";
5911        let decisions = || {
5912            vec![
5913                Decision::Approve,
5914                Decision::Deny(denial),
5915                Decision::Edit(json!({"x": 1, "y": 100})),
5916            ]
5917        };
5918
5919        let blocking_model =
5920            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
5921        let blocking_recorder = RecordingHook::default();
5922        let blocking_approver = HumanApprovalHook::new(decisions());
5923        let blocking = AgentBuilder::new(blocking_model)
5924            .tool(MockAddTool)
5925            .build()
5926            .runner("carry out the plan")
5927            .max_turns(3)
5928            .add_hook(blocking_recorder.clone())
5929            .add_hook(blocking_approver.clone())
5930            .run()
5931            .await
5932            .expect("blocking HITL run should succeed");
5933
5934        let streaming_model = MockCompletionModel::from_stream_turns(
5935            turns
5936                .iter()
5937                .map(|turn| turn.as_stream_events(StreamShape::Complete)),
5938        );
5939        let streaming_recorder = RecordingHook::default();
5940        let streaming_approver = HumanApprovalHook::new(decisions());
5941        let mut stream = AgentBuilder::new(streaming_model)
5942            .tool(MockAddTool)
5943            .build()
5944            .runner("carry out the plan")
5945            .max_turns(3)
5946            .add_hook(streaming_recorder.clone())
5947            .add_hook(streaming_approver.clone())
5948            .stream()
5949            .await;
5950        let mut final_response = None;
5951        while let Some(item) = stream.next().await {
5952            if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
5953                item.map_err(|err| panic!("stream item errored: {err}"))
5954            {
5955                final_response = Some(resp);
5956            }
5957        }
5958        let final_response = final_response.expect("stream should yield a final response");
5959
5960        // Approved (5) and edited (101) tools executed, in call order; the denied
5961        // call executed nothing but now fires a ToolResult carrying its verbatim
5962        // denial reason (structured `Skipped` outcome) — identically on both
5963        // drivers.
5964        assert_eq!(
5965            blocking_recorder.tool_results(),
5966            vec![
5967                "5".to_string(),
5968                "denied by reviewer: amount too large".to_string(),
5969                "101".to_string()
5970            ]
5971        );
5972        assert_eq!(
5973            blocking_recorder.tool_results(),
5974            streaming_recorder.tool_results()
5975        );
5976
5977        // The denied call (10 + 20) never executed, so its result 30 is absent —
5978        // the denial reason stands in its place, ruling out deny being silently
5979        // treated as approve.
5980        assert!(
5981            !blocking_recorder.tool_results().contains(&"30".to_string()),
5982            "the denied call must not have executed"
5983        );
5984
5985        // The reviewer was consulted for all three calls, in order, identically per
5986        // driver — pinning each decision to its call (approve=2+3, deny=10+20,
5987        // edit=the third).
5988        let reviewed = blocking_approver.reviewed();
5989        assert_eq!(reviewed.len(), 3);
5990        assert_eq!(reviewed, streaming_approver.reviewed());
5991        assert!(
5992            reviewed[0].contains('2') && reviewed[0].contains('3'),
5993            "first reviewed call should be add(2, 3): {reviewed:?}"
5994        );
5995        assert!(
5996            reviewed[1].contains("10") && reviewed[1].contains("20"),
5997            "the denied (second) call should be add(10, 20): {reviewed:?}"
5998        );
5999
6000        assert_eq!(blocking.output, "done");
6001        assert_eq!(final_response.output(), blocking.output);
6002        assert_eq!(
6003            blocking_recorder.shared_events(),
6004            streaming_recorder.shared_events()
6005        );
6006
6007        // Model-visible history is identical across drivers (compared structurally
6008        // as serde_json::Value) and carries the denial reason and the edited result
6009        // 101 (not the model's 1 + 1 = 2).
6010        let blocking_messages = blocking.messages.expect("blocking messages");
6011        let streaming_messages = final_response
6012            .messages()
6013            .expect("streaming history")
6014            .to_vec();
6015        assert_eq!(
6016            serde_json::to_value(&blocking_messages).expect("serialize blocking"),
6017            serde_json::to_value(&streaming_messages).expect("serialize streaming"),
6018        );
6019        assert!(
6020            tool_result_text_in_history(&blocking_messages, denial),
6021            "the denial reason must be the denied call's tool result in the history"
6022        );
6023        assert!(
6024            tool_result_text_in_history(&blocking_messages, "101"),
6025            "the edited call must have executed with the rewritten arguments"
6026        );
6027    }
6028
6029    /// A HITL hook that aborts a tool call (`Decision::Abort` -> `Flow::terminate`)
6030    /// stops the run and surfaces the reason as a `PromptCancelled` error — on both
6031    /// the blocking and streaming drivers.
6032    #[tokio::test]
6033    async fn human_in_the_loop_abort_terminates_the_run() {
6034        let turns = [
6035            ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
6036            ScriptedTurn::Text("unreachable"),
6037        ];
6038        const ABORT_REASON: &str = "aborted by the human reviewer";
6039
6040        // Blocking driver: the run resolves to a PromptCancelled error.
6041        let blocking_model =
6042            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
6043        let err = AgentBuilder::new(blocking_model)
6044            .tool(MockAddTool)
6045            .build()
6046            .runner("do the sensitive thing")
6047            .max_turns(3)
6048            .add_hook(HumanApprovalHook::new([Decision::Abort(ABORT_REASON)]))
6049            .run()
6050            .await
6051            .expect_err("an aborted tool call should terminate the blocking run");
6052        assert!(
6053            format!("{err}").contains(ABORT_REASON),
6054            "the abort reason should surface in the blocking error, got: {err}"
6055        );
6056
6057        // Streaming driver: the stream yields an error carrying the same reason and
6058        // never reaches the "unreachable" final text.
6059        let streaming_model = MockCompletionModel::from_stream_turns(
6060            turns
6061                .iter()
6062                .map(|turn| turn.as_stream_events(StreamShape::Complete)),
6063        );
6064        let mut stream = AgentBuilder::new(streaming_model)
6065            .tool(MockAddTool)
6066            .build()
6067            .runner("do the sensitive thing")
6068            .max_turns(3)
6069            .add_hook(HumanApprovalHook::new([Decision::Abort(ABORT_REASON)]))
6070            .stream()
6071            .await;
6072        let mut stream_error = None;
6073        while let Some(item) = stream.next().await {
6074            match item {
6075                Err(err) => stream_error = Some(format!("{err}")),
6076                Ok(MultiTurnStreamItem::FinalResponse(resp)) => {
6077                    panic!("aborted stream must not finalize, got: {}", resp.output())
6078                }
6079                Ok(_) => {}
6080            }
6081        }
6082        let stream_error = stream_error.expect("an aborted tool call should error the stream");
6083        assert!(
6084            stream_error.contains(ABORT_REASON),
6085            "the abort reason should surface in the streaming error, got: {stream_error}"
6086        );
6087    }
6088
6089    /// A non-interactive *policy* HITL hook: auto-approve an allow-list, deny
6090    /// everything else (fail-closed), and cache each decision so a repeated tool
6091    /// is not re-evaluated ("sticky", like the OpenAI Agents SDK's
6092    /// `always_approve`). Backs `examples/agent_with_approval_policy`.
6093    #[derive(Clone)]
6094    struct PolicyHook {
6095        auto_approve: std::collections::HashSet<&'static str>,
6096        /// Tool names the policy actually evaluated (cache misses), in order.
6097        evaluated: Arc<Mutex<Vec<String>>>,
6098        /// Sticky cache of prior decisions, keyed by tool name.
6099        cache: Arc<Mutex<std::collections::HashMap<String, bool>>>,
6100    }
6101
6102    impl PolicyHook {
6103        fn new(auto_approve: impl IntoIterator<Item = &'static str>) -> Self {
6104            Self {
6105                auto_approve: auto_approve.into_iter().collect(),
6106                evaluated: Arc::new(Mutex::new(Vec::new())),
6107                cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
6108            }
6109        }
6110
6111        fn evaluated(&self) -> Vec<String> {
6112            self.evaluated.lock().unwrap().clone()
6113        }
6114    }
6115
6116    impl<M: CompletionModel> AgentHook<M> for PolicyHook {
6117        async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
6118            let StepEvent::ToolCall { tool_name, .. } = event else {
6119                return Flow::cont();
6120            };
6121            let cached = self.cache.lock().unwrap().get(tool_name).copied();
6122            let approved = match cached {
6123                Some(decision) => decision, // sticky: reuse without re-evaluating
6124                None => {
6125                    self.evaluated.lock().unwrap().push(tool_name.to_string());
6126                    let decision = self.auto_approve.contains(tool_name);
6127                    self.cache
6128                        .lock()
6129                        .unwrap()
6130                        .insert(tool_name.to_string(), decision);
6131                    decision
6132                }
6133            };
6134            if approved {
6135                Flow::cont()
6136            } else {
6137                Flow::skip(format!("denied by policy: `{tool_name}` not allowed"))
6138            }
6139        }
6140    }
6141
6142    /// The policy hook auto-approves `add` and denies `subtract`, and its decision
6143    /// is sticky: a second `add` call reuses the cached approval instead of being
6144    /// re-evaluated. The denied call never runs and its reason reaches the model.
6145    #[tokio::test]
6146    async fn approval_policy_allow_list_with_sticky_decisions() {
6147        // One turn issues three calls: add, subtract (denied), add again (sticky).
6148        let turns = [
6149            ScriptedTurn::ToolCalls(vec![
6150                add_call("c1", 2, 3),
6151                ScriptedToolCall {
6152                    id: "c2",
6153                    name: "subtract",
6154                    args: json!({ "x": 10, "y": 4 }),
6155                },
6156                add_call("c3", 2, 3),
6157            ]),
6158            ScriptedTurn::Text("done"),
6159        ];
6160
6161        let model =
6162            MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
6163        let recorder = RecordingHook::default();
6164        let policy = PolicyHook::new(["add"]);
6165        let out = AgentBuilder::new(model)
6166            .tool(MockAddTool)
6167            .tool(MockSubtractTool)
6168            .build()
6169            .runner("go")
6170            .max_turns(3)
6171            .add_hook(recorder.clone())
6172            .add_hook(policy.clone())
6173            .run()
6174            .await
6175            .expect("policy run should succeed");
6176
6177        assert_eq!(out.output, "done");
6178        // `add` ran twice (auto-approved, then sticky-reused); `subtract` was denied
6179        // and executed nothing, but its denial reason now surfaces as a ToolResult
6180        // (structured `Skipped` outcome) between the two `add` results.
6181        assert_eq!(
6182            recorder.tool_results(),
6183            vec![
6184                "5".to_string(),
6185                "denied by policy: `subtract` not allowed".to_string(),
6186                "5".to_string()
6187            ]
6188        );
6189        // The policy evaluated each distinct tool once; the second `add` reused the
6190        // cached decision rather than being re-evaluated.
6191        assert_eq!(
6192            policy.evaluated(),
6193            vec!["add".to_string(), "subtract".to_string()]
6194        );
6195        let messages = out.messages.expect("messages");
6196        assert!(
6197            tool_result_text_in_history(&messages, "denied by policy: `subtract` not allowed"),
6198            "the policy denial reason must reach the model as the subtract tool result"
6199        );
6200    }
6201}