Skip to main content

rig_agent/test_utils/
model_conformance.rs

1//! Provider-neutral behavioral scenarios for completion-model conformance.
2//!
3//! These helpers test the model/agent contract only. Provider wire formats,
4//! authentication, HTTP streaming, and cassette matching remain provider-suite
5//! responsibilities.
6
7use std::{
8    collections::BTreeSet,
9    sync::{
10        Arc, Mutex, MutexGuard,
11        atomic::{AtomicUsize, Ordering},
12    },
13    time::{Duration, Instant},
14};
15
16use futures::StreamExt;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::{
21    agent::{
22        AgentBuilder, AgentHook, CompletionCallAction, CompletionCallEvent,
23        CompletionResponseEvent, HookContext, InvalidToolCallAction, MultiTurnStreamItem,
24        NoToolConfig, ObservationAction, OutputMode, RequestPatch, StreamingError,
25        ToolCall as ToolCallEvent, ToolCallAction, ToolResultAction, ToolResultEvent,
26        run::{AgentRun, AgentRunStep, ModelTurn, ModelTurnOutcome},
27    },
28    completion::{
29        AssistantContent, CompletionError, CompletionModel, Message, Prompt, PromptError,
30        ToolDefinition,
31    },
32    streaming::StreamingPrompt,
33    tool::{Tool, ToolContext},
34};
35use rig_core::message::{ToolChoice, UserContent};
36
37/// Typed failure from a portable model-conformance scenario.
38#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum ScenarioError {
41    /// A buffered agent run failed.
42    #[error(transparent)]
43    Prompt(#[from] PromptError),
44    /// A direct model completion failed.
45    #[error(transparent)]
46    Completion(#[from] CompletionError),
47    /// A streaming agent run failed.
48    #[error(transparent)]
49    Streaming(#[from] StreamingError),
50    /// Structured content could not be decoded.
51    #[error(transparent)]
52    Json(#[from] serde_json::Error),
53    /// Rig's structured extractor failed.
54    #[error(transparent)]
55    Extraction(#[from] crate::extractor::ExtractionError),
56    /// The model or agent violated the portable behavioral contract.
57    #[error("{scenario} conformance failed: {details}")]
58    Contract {
59        /// Stable scenario name.
60        scenario: &'static str,
61        /// Actionable observation explaining the failure.
62        details: String,
63    },
64}
65
66impl ScenarioError {
67    fn contract(scenario: &'static str, details: impl Into<String>) -> Self {
68        Self::Contract {
69            scenario,
70            details: details.into(),
71        }
72    }
73}
74
75/// Validate the portable diagnostics carried by an unknown or disallowed tool
76/// call failure.
77pub fn validate_unknown_tool_failure(
78    error: &PromptError,
79    expected_tool: &str,
80    expected_allowed_tools: &[&str],
81) -> Result<(), ScenarioError> {
82    const SCENARIO: &str = "unknown_tool_failure";
83    let PromptError::UnknownToolCall {
84        tool_name,
85        allowed_tools,
86        chat_history,
87        ..
88    } = error
89    else {
90        return Err(ScenarioError::contract(
91            SCENARIO,
92            format!("expected UnknownToolCall, observed {error:?}"),
93        ));
94    };
95    let expected_allowed = expected_allowed_tools
96        .iter()
97        .map(|name| (*name).to_string())
98        .collect::<Vec<_>>();
99    // A rejected repair target is never written back into history; diagnostics
100    // retain the model's original call while `tool_name` names the rejected
101    // target. Requiring a call-bearing assistant turn covers both paths.
102    let history_has_call = chat_history.iter().any(|message| {
103        matches!(
104            message,
105            Message::Assistant { content, .. }
106                if content.iter().any(|item| matches!(item, AssistantContent::ToolCall(_)))
107        )
108    });
109    if tool_name != expected_tool || allowed_tools != &expected_allowed || !history_has_call {
110        return Err(ScenarioError::contract(
111            SCENARIO,
112            format!(
113                "tool={tool_name:?}, allowed={allowed_tools:?}, expected_tool={expected_tool:?}, expected_allowed={expected_allowed:?}, history_has_call={history_has_call}, history={chat_history:?}"
114            ),
115        ));
116    }
117    Ok(())
118}
119
120/// Validate cancellation diagnostics, including the exact reason and retained
121/// assistant tool-call history.
122pub fn validate_cancelled_failure(
123    error: &PromptError,
124    expected_reason: &str,
125    expected_tool: &str,
126) -> Result<(), ScenarioError> {
127    const SCENARIO: &str = "cancelled_failure";
128    let PromptError::PromptCancelled {
129        chat_history,
130        reason,
131    } = error
132    else {
133        return Err(ScenarioError::contract(
134            SCENARIO,
135            format!("expected PromptCancelled, observed {error:?}"),
136        ));
137    };
138    let history_has_call = chat_history.iter().any(|message| {
139        matches!(
140            message,
141            Message::Assistant { content, .. }
142                if content.iter().any(|item| matches!(
143                    item,
144                    AssistantContent::ToolCall(call) if call.function.name == expected_tool
145                ))
146        )
147    });
148    if reason != expected_reason || !history_has_call {
149        return Err(ScenarioError::contract(
150            SCENARIO,
151            format!(
152                "reason={reason:?}, expected={expected_reason:?}, history_has_call={history_has_call}, history={chat_history:?}"
153            ),
154        ));
155    }
156    Ok(())
157}
158
159/// Validate max-turn diagnostics, including the exact configured budget and a
160/// retained pending prompt.
161pub fn validate_max_turns_failure(
162    error: &PromptError,
163    expected_max_turns: usize,
164) -> Result<(), ScenarioError> {
165    const SCENARIO: &str = "max_turns_failure";
166    let PromptError::MaxTurnsError {
167        max_turns,
168        chat_history,
169        prompt,
170    } = error
171    else {
172        return Err(ScenarioError::contract(
173            SCENARIO,
174            format!("expected MaxTurnsError, observed {error:?}"),
175        ));
176    };
177    let pending_prompt_retained = matches!(
178        prompt.as_ref(),
179        Message::User { content } if content.iter().next().is_some()
180    );
181    if *max_turns != expected_max_turns || chat_history.is_empty() || !pending_prompt_retained {
182        return Err(ScenarioError::contract(
183            SCENARIO,
184            format!(
185                "max_turns={max_turns}, expected={expected_max_turns}, history={chat_history:?}, pending_prompt_retained={pending_prompt_retained}, pending_prompt={prompt:?}"
186            ),
187        ));
188    }
189    Ok(())
190}
191
192/// Decode a structured-output response with a typed conformance failure that
193/// retains the scenario name and raw response.
194pub fn decode_structured_output<T>(
195    scenario: &'static str,
196    response: &str,
197) -> Result<T, ScenarioError>
198where
199    T: serde::de::DeserializeOwned,
200{
201    serde_json::from_str(response).map_err(|error| {
202        ScenarioError::contract(
203            scenario,
204            format!("structured output did not decode: {error}; response={response:?}"),
205        )
206    })
207}
208
209/// Validate that model-family control markers are absent from user-visible
210/// output and persisted history.
211pub fn validate_protocol_hygiene(
212    scenario: &'static str,
213    visible_output: &str,
214    messages: &[Message],
215    forbidden_markers: &[&str],
216) -> Result<(), ScenarioError> {
217    let serialized = serde_json::to_string(messages)?;
218    let leaked = forbidden_markers
219        .iter()
220        .filter(|marker| visible_output.contains(**marker) || serialized.contains(**marker))
221        .copied()
222        .collect::<Vec<_>>();
223    if !leaked.is_empty() {
224        return Err(ScenarioError::contract(
225            scenario,
226            format!(
227                "protocol markers leaked: {leaked:?}; output={visible_output:?}, history={messages:?}"
228            ),
229        ));
230    }
231    Ok(())
232}
233
234/// Validate that every observed tool invocation contains the expected rewritten
235/// argument fields while allowing unrelated original fields to remain.
236pub fn validate_rewritten_arguments(
237    scenario: &'static str,
238    observations: &[serde_json::Value],
239    expected_fields: &serde_json::Value,
240) -> Result<(), ScenarioError> {
241    let Some(expected) = expected_fields.as_object() else {
242        return Err(ScenarioError::contract(
243            scenario,
244            "expected rewritten fields must be a JSON object",
245        ));
246    };
247    if observations.is_empty() {
248        return Err(ScenarioError::contract(
249            scenario,
250            "the rewritten tool was never invoked",
251        ));
252    }
253    for observation in observations {
254        let Some(actual) = observation.as_object() else {
255            return Err(ScenarioError::contract(
256                scenario,
257                format!("observed rewritten arguments were not an object: {observation:?}"),
258            ));
259        };
260        for (key, expected_value) in expected {
261            if actual.get(key) != Some(expected_value) {
262                return Err(ScenarioError::contract(
263                    scenario,
264                    format!(
265                        "rewritten field {key:?} expected {expected_value:?}, observed {observation:?}"
266                    ),
267                ));
268            }
269        }
270    }
271    Ok(())
272}
273
274/// Validate that a sensitive raw tool result was produced but did not reach the
275/// model's user-visible response after result hooks ran.
276pub fn validate_result_redaction(
277    scenario: &'static str,
278    tool_produced_secret: bool,
279    visible_output: &str,
280    secret: &str,
281) -> Result<(), ScenarioError> {
282    if !tool_produced_secret || visible_output.is_empty() || visible_output.contains(secret) {
283        return Err(ScenarioError::contract(
284            scenario,
285            format!(
286                "produced_secret={tool_produced_secret}, secret_visible={}, output={visible_output:?}",
287                visible_output.contains(secret)
288            ),
289        ));
290    }
291    Ok(())
292}
293
294/// Validate a provider-neutral person extraction and its usage accounting.
295pub fn validate_extraction_fields(
296    scenario: &'static str,
297    first_name: Option<&str>,
298    last_name: Option<&str>,
299    job: Option<&str>,
300    usage: crate::completion::Usage,
301) -> Result<(), ScenarioError> {
302    let fields_match = first_name.is_some_and(|value| value.eq_ignore_ascii_case("Ada"))
303        && last_name.is_some_and(|value| value.eq_ignore_ascii_case("Lovelace"))
304        && job.is_some_and(|value| value.to_ascii_lowercase().contains("mathematician"));
305    if !fields_match || !usage.has_values() {
306        return Err(ScenarioError::contract(
307            scenario,
308            format!(
309                "first_name={first_name:?}, last_name={last_name:?}, job={job:?}, usage={usage:?}"
310            ),
311        ));
312    }
313    Ok(())
314}
315
316/// Summary emitted by a portable model-conformance scenario.
317#[derive(Debug, Clone)]
318pub struct ScenarioReport {
319    /// Stable scenario name.
320    pub name: &'static str,
321    /// Number of model-invoked tool calls observed by the scenario.
322    pub tool_calls: usize,
323    /// Aggregated prompt tokens reported by the model across all turns.
324    pub prompt_tokens: u64,
325    /// Aggregated generated tokens reported by the model across all turns.
326    pub generated_tokens: u64,
327    /// Number of messages retained in the completed run history.
328    pub history_messages: usize,
329    /// End-to-end scenario duration.
330    pub duration: Duration,
331    /// The model's final user-visible response.
332    pub response: String,
333}
334
335/// Error used by the deterministic conformance tools.
336#[derive(Debug, thiserror::Error)]
337#[error("model-conformance tool failed")]
338pub struct ConformanceToolError;
339
340const FORCE_TOOLS_PREAMBLE: &str = "You are a calculator assistant. You MUST use the provided tools for every arithmetic operation instead of computing results yourself. Once you have all the tool results you need, reply with the final numeric answer in plain text.";
341const PARALLEL_PROMPT: &str = "Compute 3 + 4 and 10 - 2. You MUST call the add tool and the subtract tool together in your first response, as two parallel function calls, then report both results.";
342const PING_OUTPUT: &str = "pong-crimson-7423";
343const MOTTO_OUTPUT: &str = "steady hands\ncalm waters";
344
345fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
346    mutex
347        .lock()
348        .unwrap_or_else(|poisoned| poisoned.into_inner())
349}
350
351fn tool_result_values(message: &Message) -> Vec<serde_json::Value> {
352    let Message::User { content } = message else {
353        return Vec::new();
354    };
355    content
356        .iter()
357        .filter_map(|item| match item {
358            UserContent::ToolResult(result) => Some(result),
359            _ => None,
360        })
361        .flat_map(|result| result.content.iter())
362        .filter_map(|content| match content {
363            rig_core::message::ToolResultContent::Text(text) => {
364                Some(serde_json::Value::String(text.text.clone()))
365            }
366            rig_core::message::ToolResultContent::Json { value } => Some(value.clone()),
367            rig_core::message::ToolResultContent::Image(_) => None,
368        })
369        .collect()
370}
371
372fn validate_tool_correlation(
373    scenario: &'static str,
374    messages: &[Message],
375) -> Result<(), ScenarioError> {
376    let mut calls = Vec::new();
377    let mut results = Vec::new();
378    for message in messages {
379        match message {
380            Message::Assistant { content, .. } => {
381                calls.extend(content.iter().filter_map(|item| match item {
382                    AssistantContent::ToolCall(call) => {
383                        Some((call.id.as_str(), call.call_id.as_deref()))
384                    }
385                    _ => None,
386                }));
387            }
388            Message::User { content } => {
389                results.extend(content.iter().filter_map(|item| match item {
390                    UserContent::ToolResult(result) => {
391                        Some((result.id.as_str(), result.call_id.as_deref()))
392                    }
393                    _ => None,
394                }));
395            }
396            Message::System { .. } => {}
397        }
398    }
399    if calls.is_empty() {
400        return Err(ScenarioError::contract(
401            scenario,
402            format!("history has no assistant tool calls: {messages:?}"),
403        ));
404    }
405    for (id, call_id) in &calls {
406        let matches = results
407            .iter()
408            .filter(|(result_id, result_call_id)| result_id == id && call_id == result_call_id)
409            .count();
410        if matches != 1 {
411            return Err(ScenarioError::contract(
412                scenario,
413                format!(
414                    "tool call id={id:?} call_id={call_id:?} has {matches} correlated results; calls={calls:?}, results={results:?}"
415                ),
416            ));
417        }
418    }
419    if results.len() != calls.len() {
420        return Err(ScenarioError::contract(
421            scenario,
422            format!(
423                "history contains dangling calls or results: calls={calls:?}, results={results:?}"
424            ),
425        ));
426    }
427    Ok(())
428}
429
430#[derive(Debug, Deserialize, JsonSchema)]
431struct OperationArgs {
432    x: i64,
433    y: i64,
434}
435
436#[derive(Clone)]
437struct CountingAdd(Arc<AtomicUsize>);
438
439impl Tool for CountingAdd {
440    const NAME: &'static str = "add";
441    type Error = ConformanceToolError;
442    type Args = OperationArgs;
443    type Output = i64;
444
445    fn description(&self) -> String {
446        "Add x and y together".to_string()
447    }
448
449    fn parameters(&self) -> serde_json::Value {
450        serde_json::json!({
451            "type": "object",
452            "properties": {
453                "x": { "type": "number", "description": "The first operand" },
454                "y": { "type": "number", "description": "The second operand" }
455            },
456            "required": ["x", "y"]
457        })
458    }
459
460    async fn call(
461        &self,
462        _context: &mut ToolContext,
463        args: Self::Args,
464    ) -> Result<Self::Output, Self::Error> {
465        self.0.fetch_add(1, Ordering::SeqCst);
466        Ok(args.x + args.y)
467    }
468}
469
470#[derive(Clone)]
471struct CountingSum(Arc<AtomicUsize>);
472
473impl Tool for CountingSum {
474    const NAME: &'static str = "sum";
475    type Error = ConformanceToolError;
476    type Args = OperationArgs;
477    type Output = i64;
478
479    fn description(&self) -> String {
480        "Add x and y together (alias of add)".to_string()
481    }
482
483    fn parameters(&self) -> serde_json::Value {
484        CountingAdd(Arc::new(AtomicUsize::new(0))).parameters()
485    }
486
487    async fn call(
488        &self,
489        _context: &mut ToolContext,
490        args: Self::Args,
491    ) -> Result<Self::Output, Self::Error> {
492        self.0.fetch_add(1, Ordering::SeqCst);
493        Ok(args.x + args.y)
494    }
495}
496
497#[derive(Clone)]
498struct CountingSubtract(Arc<AtomicUsize>);
499
500impl Tool for CountingSubtract {
501    const NAME: &'static str = "subtract";
502    type Error = ConformanceToolError;
503    type Args = OperationArgs;
504    type Output = i64;
505
506    fn description(&self) -> String {
507        "Subtract y from x (i.e. x - y)".to_string()
508    }
509
510    fn parameters(&self) -> serde_json::Value {
511        serde_json::json!({
512            "type": "object",
513            "properties": {
514                "x": { "type": "number", "description": "The first operand" },
515                "y": { "type": "number", "description": "The second operand" }
516            },
517            "required": ["x", "y"]
518        })
519    }
520
521    async fn call(
522        &self,
523        _context: &mut ToolContext,
524        args: Self::Args,
525    ) -> Result<Self::Output, Self::Error> {
526        self.0.fetch_add(1, Ordering::SeqCst);
527        Ok(args.x - args.y)
528    }
529}
530
531#[derive(Clone)]
532struct RewriteArgument {
533    key: &'static str,
534    value: serde_json::Value,
535}
536
537impl AgentHook for RewriteArgument {
538    async fn on_tool_call(&self, _ctx: &HookContext, event: ToolCallEvent<'_>) -> ToolCallAction {
539        if event.tool_name != CountingAdd::NAME {
540            return ToolCallAction::run();
541        }
542        let Ok(mut arguments) = serde_json::from_str::<serde_json::Value>(event.args) else {
543            return ToolCallAction::run();
544        };
545        let Some(object) = arguments.as_object_mut() else {
546            return ToolCallAction::run();
547        };
548        object.insert(self.key.to_string(), self.value.clone());
549        ToolCallAction::rewrite(arguments)
550    }
551}
552
553#[derive(Clone, Default)]
554struct ObserveArguments(Arc<Mutex<Vec<serde_json::Value>>>);
555
556impl AgentHook for ObserveArguments {
557    async fn on_tool_call(&self, _ctx: &HookContext, event: ToolCallEvent<'_>) -> ToolCallAction {
558        let value = serde_json::from_str(event.args)
559            .unwrap_or_else(|_| serde_json::Value::String(event.args.to_string()));
560        lock_recover(&self.0).push(value);
561        ToolCallAction::run()
562    }
563}
564
565#[derive(Clone)]
566struct ReplaceResult(&'static str);
567
568impl AgentHook for ReplaceResult {
569    async fn on_tool_result(
570        &self,
571        _ctx: &HookContext,
572        event: ToolResultEvent<'_>,
573    ) -> ToolResultAction {
574        if event.tool_name == CountingAdd::NAME {
575            ToolResultAction::rewrite(self.0)
576        } else {
577            ToolResultAction::keep()
578        }
579    }
580}
581
582#[derive(Clone)]
583struct WrapResult;
584
585impl AgentHook for WrapResult {
586    async fn on_tool_result(
587        &self,
588        _ctx: &HookContext,
589        event: ToolResultEvent<'_>,
590    ) -> ToolResultAction {
591        if event.tool_name == CountingAdd::NAME {
592            ToolResultAction::rewrite(format!("[{}]", event.presentation.render()))
593        } else {
594            ToolResultAction::keep()
595        }
596    }
597}
598
599#[derive(Clone)]
600struct FirstTurnPatch(RequestPatch);
601
602impl AgentHook for FirstTurnPatch {
603    async fn on_completion_call(
604        &self,
605        ctx: &HookContext,
606        _event: CompletionCallEvent<'_>,
607    ) -> CompletionCallAction {
608        if ctx.turn() == 1 {
609            CompletionCallAction::patch(self.0.clone())
610        } else {
611            CompletionCallAction::continue_run()
612        }
613    }
614}
615
616#[derive(Clone)]
617struct StopAfterResult(&'static str);
618
619impl AgentHook for StopAfterResult {
620    async fn on_tool_result(
621        &self,
622        _ctx: &HookContext,
623        event: ToolResultEvent<'_>,
624    ) -> ToolResultAction {
625        if event.tool_name == CountingAdd::NAME {
626            ToolResultAction::stop(self.0)
627        } else {
628            ToolResultAction::keep()
629        }
630    }
631}
632
633#[derive(Debug, Deserialize, JsonSchema)]
634struct EmptyArgs {}
635
636#[derive(Clone)]
637struct PingTool(Arc<AtomicUsize>);
638
639impl Tool for PingTool {
640    const NAME: &'static str = "ping";
641    type Error = ConformanceToolError;
642    type Args = EmptyArgs;
643    type Output = String;
644
645    fn description(&self) -> String {
646        "Return the current ping marker. Takes no arguments.".to_string()
647    }
648
649    fn parameters(&self) -> serde_json::Value {
650        serde_json::json!({ "type": "object", "properties": {}, "required": [] })
651    }
652
653    async fn call(
654        &self,
655        _context: &mut ToolContext,
656        _args: Self::Args,
657    ) -> Result<Self::Output, Self::Error> {
658        self.0.fetch_add(1, Ordering::SeqCst);
659        Ok(PING_OUTPUT.to_string())
660    }
661}
662
663#[derive(Clone)]
664struct MottoTool(Arc<AtomicUsize>);
665
666impl Tool for MottoTool {
667    const NAME: &'static str = "fetch_motto";
668    type Error = ConformanceToolError;
669    type Args = EmptyArgs;
670    type Output = String;
671
672    fn description(&self) -> String {
673        "Fetch the two-line workshop motto.".to_string()
674    }
675
676    fn parameters(&self) -> serde_json::Value {
677        serde_json::json!({ "type": "object", "properties": {}, "required": [] })
678    }
679
680    async fn call(
681        &self,
682        _context: &mut ToolContext,
683        _args: Self::Args,
684    ) -> Result<Self::Output, Self::Error> {
685        self.0.fetch_add(1, Ordering::SeqCst);
686        Ok(MOTTO_OUTPUT.to_string())
687    }
688}
689
690#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
691struct ConfigOutput {
692    service: String,
693    max_retries: u64,
694}
695
696#[derive(Clone)]
697struct ConfigTool(Arc<AtomicUsize>);
698
699impl Tool for ConfigTool {
700    const NAME: &'static str = "fetch_config";
701    type Error = ConformanceToolError;
702    type Args = EmptyArgs;
703    type Output = ConfigOutput;
704
705    fn description(&self) -> String {
706        "Fetch the service configuration object.".to_string()
707    }
708
709    fn parameters(&self) -> serde_json::Value {
710        serde_json::json!({ "type": "object", "properties": {}, "required": [] })
711    }
712
713    async fn call(
714        &self,
715        _context: &mut ToolContext,
716        _args: Self::Args,
717    ) -> Result<Self::Output, Self::Error> {
718        self.0.fetch_add(1, Ordering::SeqCst);
719        Ok(ConfigOutput {
720            service: "cassette-lab".to_string(),
721            max_retries: 3,
722        })
723    }
724}
725
726#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
727#[serde(rename_all = "snake_case")]
728enum ComplexMode {
729    Careful,
730    Fast,
731}
732
733#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
734struct ComplexProfile {
735    name: String,
736    tags: Vec<String>,
737}
738
739#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
740struct ComplexArgs {
741    profile: ComplexProfile,
742    mode: ComplexMode,
743    note: Option<String>,
744    quote: String,
745}
746
747#[derive(Debug, Deserialize, Serialize, JsonSchema)]
748struct ExtractedPerson {
749    #[schemars(required)]
750    first_name: Option<String>,
751    #[schemars(required)]
752    last_name: Option<String>,
753    #[schemars(required)]
754    job: Option<String>,
755}
756
757#[derive(Clone)]
758struct CaptureComplexTool {
759    calls: Arc<AtomicUsize>,
760    captured: Arc<Mutex<Option<ComplexArgs>>>,
761}
762
763impl Tool for CaptureComplexTool {
764    const NAME: &'static str = "store_profile";
765    type Error = ConformanceToolError;
766    type Args = ComplexArgs;
767    type Output = ComplexArgs;
768
769    fn description(&self) -> String {
770        "Store one profile with its nested tags, mode, optional note, and exact quoted text."
771            .to_string()
772    }
773
774    fn parameters(&self) -> serde_json::Value {
775        serde_json::to_value(schemars::schema_for!(ComplexArgs)).unwrap_or_default()
776    }
777
778    async fn call(
779        &self,
780        _context: &mut ToolContext,
781        args: Self::Args,
782    ) -> Result<Self::Output, Self::Error> {
783        self.calls.fetch_add(1, Ordering::SeqCst);
784        *lock_recover(&self.captured) = Some(args.clone());
785        Ok(args)
786    }
787}
788
789fn has_tool_roundtrip(messages: Option<&[Message]>) -> bool {
790    let saw_call = messages.is_some_and(|messages| {
791        messages.iter().any(|message| {
792            matches!(
793                message,
794                Message::Assistant { content, .. }
795                    if content.iter().any(|item| matches!(item, AssistantContent::ToolCall(_)))
796            )
797        })
798    });
799    let saw_result = messages.is_some_and(|messages| {
800        messages.iter().any(|message| {
801            matches!(
802                message,
803                Message::User { content }
804                    if content.iter().any(|item| matches!(item, UserContent::ToolResult(_)))
805            )
806        })
807    });
808    saw_call && saw_result
809}
810
811#[derive(Debug, Deserialize, JsonSchema)]
812struct RepeatArgs {
813    /// The text to repeat.
814    text: String,
815    /// Number of repetitions; defaults to 2 when omitted.
816    times: Option<u32>,
817}
818
819#[derive(Clone)]
820struct RepeatTool {
821    calls: Arc<AtomicUsize>,
822}
823
824impl Tool for RepeatTool {
825    const NAME: &'static str = "repeat_text";
826    type Error = ConformanceToolError;
827    type Args = RepeatArgs;
828    type Output = String;
829
830    fn description(&self) -> String {
831        "Repeat `text`. `times` is optional and defaults to 2.".to_string()
832    }
833
834    fn parameters(&self) -> serde_json::Value {
835        serde_json::to_value(schemars::schema_for!(RepeatArgs)).unwrap_or_default()
836    }
837
838    async fn call(
839        &self,
840        _context: &mut ToolContext,
841        args: Self::Args,
842    ) -> Result<Self::Output, Self::Error> {
843        self.calls.fetch_add(1, Ordering::SeqCst);
844        Ok(vec![args.text.as_str(); args.times.unwrap_or(2) as usize].join(" "))
845    }
846}
847
848#[derive(Debug, Deserialize, JsonSchema)]
849struct BinOpArgs {
850    a: i64,
851    b: i64,
852}
853
854#[derive(Debug, Deserialize, JsonSchema)]
855struct ArithmeticResult {
856    answer: i64,
857    explanation: Option<String>,
858}
859
860#[derive(Clone)]
861struct AddTool(Arc<AtomicUsize>);
862
863impl Tool for AddTool {
864    const NAME: &'static str = "add";
865    type Error = ConformanceToolError;
866    type Args = BinOpArgs;
867    type Output = i64;
868
869    fn description(&self) -> String {
870        "Add two integers a and b.".to_string()
871    }
872
873    fn parameters(&self) -> serde_json::Value {
874        serde_json::to_value(schemars::schema_for!(BinOpArgs)).unwrap_or_default()
875    }
876
877    async fn call(
878        &self,
879        _context: &mut ToolContext,
880        args: Self::Args,
881    ) -> Result<Self::Output, Self::Error> {
882        self.0.fetch_add(1, Ordering::SeqCst);
883        Ok(args.a + args.b)
884    }
885}
886
887#[derive(Clone)]
888struct MultiplyTool(Arc<AtomicUsize>);
889
890impl Tool for MultiplyTool {
891    const NAME: &'static str = "multiply";
892    type Error = ConformanceToolError;
893    type Args = BinOpArgs;
894    type Output = i64;
895
896    fn description(&self) -> String {
897        "Multiply two integers a and b.".to_string()
898    }
899
900    fn parameters(&self) -> serde_json::Value {
901        serde_json::to_value(schemars::schema_for!(BinOpArgs)).unwrap_or_default()
902    }
903
904    async fn call(
905        &self,
906        _context: &mut ToolContext,
907        args: Self::Args,
908    ) -> Result<Self::Output, Self::Error> {
909        self.0.fetch_add(1, Ordering::SeqCst);
910        Ok(args.a * args.b)
911    }
912}
913
914fn report_from_response(
915    name: &'static str,
916    started: Instant,
917    tool_calls: usize,
918    response: crate::agent::PromptResponse,
919) -> Result<ScenarioReport, ScenarioError> {
920    if let Some(messages) = response.messages.as_deref() {
921        validate_protocol_hygiene(
922            name,
923            &response.output,
924            messages,
925            &[
926                "<tool_call>",
927                "</tool_call>",
928                "<tool_response>",
929                "</tool_response>",
930                "<|im_start|>",
931                "<|im_end|>",
932                "<think>",
933                "</think>",
934            ],
935        )?;
936    }
937    Ok(ScenarioReport {
938        name,
939        tool_calls,
940        prompt_tokens: response.usage.input_tokens,
941        generated_tokens: response.usage.output_tokens,
942        history_messages: response.messages.as_ref().map_or(0, Vec::len),
943        duration: started.elapsed(),
944        response: response.output,
945    })
946}
947
948fn value_matches_integer(value: &serde_json::Value, expected: i64) -> bool {
949    value.as_i64() == Some(expected)
950        || value
951            .as_str()
952            .and_then(|text| text.trim().parse::<i64>().ok())
953            == Some(expected)
954}
955
956/// Runs two independent calls in one assistant turn and validates canonical
957/// call/result history correlation.
958///
959/// Set `tool_concurrency` to `Some(1)` to prove that serial host execution does
960/// not split or drop the model's parallel call batch.
961pub async fn parallel_tools<M, F>(
962    model: M,
963    configure: F,
964    tool_concurrency: Option<usize>,
965) -> Result<ScenarioReport, ScenarioError>
966where
967    M: CompletionModel + 'static,
968    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
969{
970    let add_calls = Arc::new(AtomicUsize::new(0));
971    let subtract_calls = Arc::new(AtomicUsize::new(0));
972    let started = Instant::now();
973    let agent = configure(AgentBuilder::new(model))
974        .preamble(FORCE_TOOLS_PREAMBLE)
975        .temperature(0.0)
976        .tool(CountingAdd(add_calls.clone()))
977        .tool(CountingSubtract(subtract_calls.clone()))
978        .default_max_turns(3)
979        .build();
980    let request = agent.prompt(PARALLEL_PROMPT).max_turns(3);
981    let response = match tool_concurrency {
982        Some(concurrency) => {
983            request
984                .tool_concurrency(concurrency)
985                .extended_details()
986                .await?
987        }
988        None => request.extended_details().await?,
989    };
990    let scenario = if tool_concurrency == Some(1) {
991        "parallel_tools_serial_execution"
992    } else {
993        "parallel_tools"
994    };
995    let messages = response.messages.as_deref().ok_or_else(|| {
996        ScenarioError::contract(scenario, "extended run omitted accumulated message history")
997    })?;
998    validate_tool_correlation(scenario, messages)?;
999
1000    let Some((call_index, calls)) = messages.iter().enumerate().find_map(|(index, message)| {
1001        let Message::Assistant { content, .. } = message else {
1002            return None;
1003        };
1004        let calls = content
1005            .iter()
1006            .filter_map(|item| match item {
1007                AssistantContent::ToolCall(call) => Some(call),
1008                _ => None,
1009            })
1010            .collect::<Vec<_>>();
1011        (calls.len() == 2).then_some((index, calls))
1012    }) else {
1013        return Err(ScenarioError::contract(
1014            scenario,
1015            format!("no assistant turn contained exactly two tool calls: {messages:?}"),
1016        ));
1017    };
1018    let mut names = calls
1019        .iter()
1020        .map(|call| call.function.name.as_str())
1021        .collect::<Vec<_>>();
1022    names.sort_unstable();
1023    if names != ["add", "subtract"] {
1024        return Err(ScenarioError::contract(
1025            scenario,
1026            format!("parallel turn called {names:?}, expected add and subtract"),
1027        ));
1028    }
1029    let results_message = messages.get(call_index + 1).ok_or_else(|| {
1030        ScenarioError::contract(
1031            scenario,
1032            "parallel call turn has no following result message",
1033        )
1034    })?;
1035    let values = tool_result_values(results_message);
1036    if !values.iter().any(|value| value_matches_integer(value, 7))
1037        || !values.iter().any(|value| value_matches_integer(value, 8))
1038        || values.len() != 2
1039    {
1040        return Err(ScenarioError::contract(
1041            scenario,
1042            format!("parallel result message did not contain exactly 7 and 8: {values:?}"),
1043        ));
1044    }
1045    let add = add_calls.load(Ordering::SeqCst);
1046    let subtract = subtract_calls.load(Ordering::SeqCst);
1047    if add != 1 || subtract != 1 {
1048        return Err(ScenarioError::contract(
1049            scenario,
1050            format!("execution counts were add={add}, subtract={subtract}, expected one each"),
1051        ));
1052    }
1053    report_from_response(scenario, started, add + subtract, response)
1054}
1055
1056/// Runs a zero-argument tool and validates verbatim string-result handling.
1057pub async fn zero_argument_tool<M, F>(
1058    model: M,
1059    configure: F,
1060) -> Result<ScenarioReport, ScenarioError>
1061where
1062    M: CompletionModel + 'static,
1063    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1064{
1065    const SCENARIO: &str = "zero_argument_tool";
1066    let calls = Arc::new(AtomicUsize::new(0));
1067    let started = Instant::now();
1068    let agent = configure(AgentBuilder::new(model))
1069        .preamble("You must use the provided tools. Report tool outputs exactly as returned.")
1070        .temperature(0.0)
1071        .tool(PingTool(calls.clone()))
1072        .default_max_turns(2)
1073        .build();
1074    let response = agent
1075        .prompt("Call the ping tool, then report the exact marker it returns.")
1076        .max_turns(2)
1077        .extended_details()
1078        .await?;
1079    let messages = response.messages.as_deref().ok_or_else(|| {
1080        ScenarioError::contract(SCENARIO, "extended run omitted accumulated message history")
1081    })?;
1082    validate_tool_correlation(SCENARIO, messages)?;
1083    let values = messages
1084        .iter()
1085        .flat_map(tool_result_values)
1086        .collect::<Vec<_>>();
1087    if calls.load(Ordering::SeqCst) != 1
1088        || !values
1089            .iter()
1090            .any(|value| value.as_str() == Some(PING_OUTPUT))
1091        || !response.output.contains(PING_OUTPUT)
1092    {
1093        return Err(ScenarioError::contract(
1094            SCENARIO,
1095            format!(
1096                "calls={}, results={values:?}, response={:?}",
1097                calls.load(Ordering::SeqCst),
1098                response.output
1099            ),
1100        ));
1101    }
1102    report_from_response(SCENARIO, started, 1, response)
1103}
1104
1105/// Runs string- and JSON-returning tools and validates that neither output is
1106/// double encoded.
1107pub async fn tool_output_serialization<M, F>(
1108    model: M,
1109    configure: F,
1110) -> Result<ScenarioReport, ScenarioError>
1111where
1112    M: CompletionModel + 'static,
1113    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1114{
1115    const SCENARIO: &str = "tool_output_serialization";
1116    let started = Instant::now();
1117    let motto_calls = Arc::new(AtomicUsize::new(0));
1118    let config_calls = Arc::new(AtomicUsize::new(0));
1119    let agent = configure(AgentBuilder::new(model))
1120        .preamble("You must use the provided tools before answering.")
1121        .temperature(0.0)
1122        .tool(MottoTool(motto_calls.clone()))
1123        .tool(ConfigTool(config_calls.clone()))
1124        .default_max_turns(3)
1125        .build();
1126    let response = agent
1127        .prompt("Call fetch_motto and fetch_config, then summarize both outputs in one sentence.")
1128        .max_turns(3)
1129        .extended_details()
1130        .await?;
1131    let messages = response.messages.as_deref().ok_or_else(|| {
1132        ScenarioError::contract(SCENARIO, "extended run omitted accumulated message history")
1133    })?;
1134    validate_tool_correlation(SCENARIO, messages)?;
1135    let values = messages
1136        .iter()
1137        .flat_map(tool_result_values)
1138        .collect::<Vec<_>>();
1139    let expected_config = serde_json::to_value(ConfigOutput {
1140        service: "cassette-lab".to_string(),
1141        max_retries: 3,
1142    })?;
1143    let motto_ok = values
1144        .iter()
1145        .any(|value| value.as_str() == Some(MOTTO_OUTPUT));
1146    let config_ok = values.iter().any(|value| {
1147        value == &expected_config
1148            || value
1149                .as_str()
1150                .and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok())
1151                .as_ref()
1152                == Some(&expected_config)
1153    });
1154    let motto_count = motto_calls.load(Ordering::SeqCst);
1155    let config_count = config_calls.load(Ordering::SeqCst);
1156    if !motto_ok || !config_ok || motto_count != 1 || config_count != 1 {
1157        return Err(ScenarioError::contract(
1158            SCENARIO,
1159            format!(
1160                "expected one verbatim motto and one semantic config JSON; motto_calls={motto_count}, config_calls={config_count}, values={values:?}"
1161            ),
1162        ));
1163    }
1164    report_from_response(SCENARIO, started, motto_count + config_count, response)
1165}
1166
1167/// Runs a nested, escaped, Unicode-bearing argument payload through typed tool
1168/// deserialization and validates the exact semantic value received by the tool.
1169pub async fn complex_tool_arguments<M, F>(
1170    model: M,
1171    configure: F,
1172) -> Result<ScenarioReport, ScenarioError>
1173where
1174    M: CompletionModel + 'static,
1175    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1176{
1177    const SCENARIO: &str = "complex_tool_arguments";
1178    let expected = ComplexArgs {
1179        profile: ComplexProfile {
1180            name: "Zoë \"Z\"".to_string(),
1181            tags: vec!["rust".to_string(), "東京".to_string()],
1182        },
1183        mode: ComplexMode::Careful,
1184        note: Some("line one\nline two".to_string()),
1185        quote: "path C:\\tmp and \"quoted\"".to_string(),
1186    };
1187    let calls = Arc::new(AtomicUsize::new(0));
1188    let captured = Arc::new(Mutex::new(None));
1189    let started = Instant::now();
1190    let agent = configure(AgentBuilder::new(model))
1191        .preamble("Use store_profile exactly once with every value supplied by the user.")
1192        .temperature(0.0)
1193        .tool(CaptureComplexTool {
1194            calls: calls.clone(),
1195            captured: captured.clone(),
1196        })
1197        .default_max_turns(3)
1198        .build();
1199    let response = agent
1200        .prompt(
1201            "Call store_profile with profile.name exactly `Zoë \\\"Z\\\"`, profile.tags exactly [`rust`, `東京`], mode `careful`, note containing the two lines `line one` and `line two` separated by a newline, and quote exactly `path C:\\\\tmp and \\\"quoted\\\"`. Then confirm it was stored.",
1202        )
1203        .max_turns(3)
1204        .extended_details()
1205        .await?;
1206    let observed = lock_recover(&captured).clone();
1207    if calls.load(Ordering::SeqCst) != 1 || observed.as_ref() != Some(&expected) {
1208        return Err(ScenarioError::contract(
1209            SCENARIO,
1210            format!(
1211                "calls={}, expected={expected:?}, observed={observed:?}, response={:?}",
1212                calls.load(Ordering::SeqCst),
1213                response.output
1214            ),
1215        ));
1216    }
1217    let messages = response.messages.as_deref().ok_or_else(|| {
1218        ScenarioError::contract(SCENARIO, "extended run omitted accumulated message history")
1219    })?;
1220    validate_tool_correlation(SCENARIO, messages)?;
1221    report_from_response(SCENARIO, started, 1, response)
1222}
1223
1224/// Runs the same deterministic text request through buffered and raw streaming
1225/// completion surfaces and validates equivalent visible content and usage.
1226pub async fn buffered_streaming_text_parity<M>(model: M) -> Result<ScenarioReport, ScenarioError>
1227where
1228    M: CompletionModel + Clone + 'static,
1229{
1230    const SCENARIO: &str = "buffered_streaming_text_parity";
1231    const PROMPT: &str = "Answer with exactly the single word Paris.";
1232    let started = Instant::now();
1233    let request = || {
1234        model
1235            .completion_request(PROMPT)
1236            .temperature(0.0)
1237            .max_tokens(32)
1238            .build()
1239    };
1240    let buffered = model.completion(request()).await?;
1241    let buffered_text = buffered
1242        .choice
1243        .iter()
1244        .filter_map(|item| match item {
1245            AssistantContent::Text(text) => Some(text.text.as_str()),
1246            _ => None,
1247        })
1248        .collect::<String>();
1249
1250    let mut stream = model.stream(request()).await?;
1251    let mut streamed_text = String::new();
1252    let mut streamed_usage = None;
1253    while let Some(item) = stream.next().await {
1254        match item? {
1255            crate::streaming::StreamedAssistantContent::Text(text) => {
1256                streamed_text.push_str(&text.text);
1257            }
1258            crate::streaming::StreamedAssistantContent::Final(response) => {
1259                streamed_usage = Some(crate::completion::GetTokenUsage::token_usage(&response));
1260            }
1261            crate::streaming::StreamedAssistantContent::ToolCall { .. }
1262            | crate::streaming::StreamedAssistantContent::ToolCallDelta { .. }
1263            | crate::streaming::StreamedAssistantContent::Reasoning(_)
1264            | crate::streaming::StreamedAssistantContent::ReasoningDelta { .. }
1265            | crate::streaming::StreamedAssistantContent::Unknown(_) => {}
1266        }
1267    }
1268    let usage = streamed_usage.ok_or_else(|| {
1269        ScenarioError::contract(SCENARIO, "raw stream omitted its final response metadata")
1270    })?;
1271    let normalize = |text: &str| {
1272        text.trim()
1273            .trim_matches(|character: char| !character.is_alphanumeric())
1274            .to_string()
1275    };
1276    let buffered_answer = normalize(&buffered_text);
1277    let streamed_answer = normalize(&streamed_text);
1278    if !buffered_answer.eq_ignore_ascii_case("Paris")
1279        || !streamed_answer.eq_ignore_ascii_case("Paris")
1280        || !buffered.usage.has_values()
1281        || !usage.has_values()
1282    {
1283        return Err(ScenarioError::contract(
1284            SCENARIO,
1285            format!(
1286                "buffered={buffered_text:?}, streamed={streamed_text:?}, buffered_usage={:?}, streamed_usage={usage:?}",
1287                buffered.usage
1288            ),
1289        ));
1290    }
1291    Ok(ScenarioReport {
1292        name: SCENARIO,
1293        tool_calls: 0,
1294        prompt_tokens: usage.input_tokens,
1295        generated_tokens: usage.output_tokens,
1296        history_messages: 0,
1297        duration: started.elapsed(),
1298        response: streamed_text,
1299    })
1300}
1301
1302/// Runs Rig's structured extractor and validates both the extracted semantic
1303/// fields and accumulated usage.
1304pub async fn structured_extraction<M>(model: M) -> Result<ScenarioReport, ScenarioError>
1305where
1306    M: CompletionModel + 'static,
1307{
1308    const SCENARIO: &str = "structured_extraction";
1309    const INPUT: &str = "Hello, my name is Ada Lovelace and I work as a mathematician.";
1310    let started = Instant::now();
1311    let response = crate::extractor::ExtractorBuilder::<M, ExtractedPerson>::new(model)
1312        .max_tokens(384)
1313        .retries(0)
1314        .build()
1315        .extract_with_usage(INPUT)
1316        .await?;
1317    validate_extraction_fields(
1318        SCENARIO,
1319        response.data.first_name.as_deref(),
1320        response.data.last_name.as_deref(),
1321        response.data.job.as_deref(),
1322        response.usage,
1323    )?;
1324    Ok(ScenarioReport {
1325        name: SCENARIO,
1326        tool_calls: 1,
1327        prompt_tokens: response.usage.input_tokens,
1328        generated_tokens: response.usage.output_tokens,
1329        history_messages: 0,
1330        duration: started.elapsed(),
1331        response: format!(
1332            "{} {} — {}",
1333            response.data.first_name.as_deref().unwrap_or_default(),
1334            response.data.last_name.as_deref().unwrap_or_default(),
1335            response.data.job.as_deref().unwrap_or_default()
1336        ),
1337    })
1338}
1339
1340fn restricted_recovery_run(
1341    prompt: &str,
1342    turn: ModelTurn,
1343    retries: usize,
1344) -> Result<AgentRun, ScenarioError> {
1345    const SCENARIO: &str = "invalid_tool_recovery";
1346    let mut run = AgentRun::new(prompt)
1347        .max_turns(2)
1348        .max_invalid_tool_call_retries(retries);
1349    if !matches!(run.next_step()?, AgentRunStep::CallModel { .. }) {
1350        return Err(ScenarioError::contract(
1351            SCENARIO,
1352            "fresh AgentRun did not request a model turn",
1353        ));
1354    }
1355    let outcome = run.model_response(turn)?;
1356    let ModelTurnOutcome::NeedsResolution(context) = outcome else {
1357        return Err(ScenarioError::contract(
1358            SCENARIO,
1359            format!("disallowed tool call did not require resolution: {outcome:?}"),
1360        ));
1361    };
1362    if context.tool_name != CountingAdd::NAME {
1363        return Err(ScenarioError::contract(
1364            SCENARIO,
1365            format!("expected rejected add call, observed {context:?}"),
1366        ));
1367    }
1368    Ok(run)
1369}
1370
1371/// Uses one real model turn to exercise fail-fast, retry exhaustion, repair,
1372/// rejected repair, and skip handling without executing a disallowed call.
1373pub async fn invalid_tool_recovery<M, F>(
1374    model: M,
1375    configure: F,
1376) -> Result<ScenarioReport, ScenarioError>
1377where
1378    M: CompletionModel + 'static,
1379    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1380{
1381    const SCENARIO: &str = "invalid_tool_recovery";
1382    const PROMPT: &str = "Call the add tool exactly once with x=2 and y=3. Do not call sum.";
1383    let started = Instant::now();
1384    let add_calls = Arc::new(AtomicUsize::new(0));
1385    let sum_calls = Arc::new(AtomicUsize::new(0));
1386    let agent = configure(AgentBuilder::new(model))
1387        .preamble(FORCE_TOOLS_PREAMBLE)
1388        .temperature(0.0)
1389        .tool(CountingAdd(add_calls.clone()))
1390        .tool(CountingSum(sum_calls.clone()))
1391        .tool_choice(ToolChoice::Required)
1392        .build();
1393    #[derive(Clone)]
1394    struct CaptureTurn(Arc<Mutex<Option<ModelTurn>>>);
1395
1396    impl AgentHook for CaptureTurn {
1397        async fn on_completion_response(
1398            &self,
1399            _ctx: &HookContext,
1400            event: CompletionResponseEvent<'_>,
1401        ) -> ObservationAction {
1402            *lock_recover(&self.0) = Some(ModelTurn::new(
1403                event.message_id.map(str::to_owned),
1404                event.content.clone(),
1405                event.usage,
1406                BTreeSet::new(),
1407                BTreeSet::new(),
1408            ));
1409            ObservationAction::stop("captured conformance model turn")
1410        }
1411    }
1412
1413    let captured = Arc::new(Mutex::new(None));
1414    let stopped = agent
1415        .runner(PROMPT)
1416        .add_hook(CaptureTurn(captured.clone()))
1417        .run()
1418        .await;
1419    if !matches!(stopped, Err(PromptError::PromptCancelled { .. })) {
1420        return Err(ScenarioError::contract(
1421            SCENARIO,
1422            format!("capture hook did not stop after the model response: {stopped:?}"),
1423        ));
1424    }
1425    let response = lock_recover(&captured).take().ok_or_else(|| {
1426        ScenarioError::contract(SCENARIO, "capture hook observed no model response")
1427    })?;
1428    let emitted = response
1429        .choice
1430        .iter()
1431        .filter(|item| {
1432            matches!(item, AssistantContent::ToolCall(call) if call.function.name == CountingAdd::NAME)
1433        })
1434        .count();
1435    if emitted != 1 {
1436        return Err(ScenarioError::contract(
1437            SCENARIO,
1438            format!(
1439                "model emitted {emitted} add calls, response={:?}",
1440                response.choice
1441            ),
1442        ));
1443    }
1444    let executable = BTreeSet::from([CountingAdd::NAME.to_string(), CountingSum::NAME.to_string()]);
1445    let allowed = BTreeSet::from([CountingSum::NAME.to_string()]);
1446    let turn = ModelTurn::new(
1447        response.message_id,
1448        response.choice,
1449        response.usage,
1450        executable,
1451        allowed,
1452    );
1453
1454    let mut fail = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1455    let error = match fail.resolve_invalid_tool_call(InvalidToolCallAction::fail()) {
1456        Err(error) => error,
1457        Ok(outcome) => {
1458            return Err(ScenarioError::contract(
1459                SCENARIO,
1460                format!("fail action unexpectedly returned {outcome:?}"),
1461            ));
1462        }
1463    };
1464    validate_unknown_tool_failure(&error, CountingAdd::NAME, &[CountingSum::NAME])?;
1465
1466    let mut retry = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1467    let error = match retry
1468        .resolve_invalid_tool_call(InvalidToolCallAction::retry("choose an allowed tool"))
1469    {
1470        Err(error) => error,
1471        Ok(outcome) => {
1472            return Err(ScenarioError::contract(
1473                SCENARIO,
1474                format!("exhausted retry unexpectedly returned {outcome:?}"),
1475            ));
1476        }
1477    };
1478    validate_unknown_tool_failure(&error, CountingAdd::NAME, &[CountingSum::NAME])?;
1479
1480    let mut rejected_repair = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1481    let error =
1482        match rejected_repair.resolve_invalid_tool_call(InvalidToolCallAction::repair("missing")) {
1483            Err(error) => error,
1484            Ok(outcome) => {
1485                return Err(ScenarioError::contract(
1486                    SCENARIO,
1487                    format!("disallowed repair unexpectedly returned {outcome:?}"),
1488                ));
1489            }
1490        };
1491    validate_unknown_tool_failure(&error, "missing", &[CountingSum::NAME])?;
1492
1493    let mut repaired = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1494    if !matches!(
1495        repaired.resolve_invalid_tool_call(InvalidToolCallAction::repair(CountingSum::NAME))?,
1496        ModelTurnOutcome::Continue { .. }
1497    ) {
1498        return Err(ScenarioError::contract(
1499            SCENARIO,
1500            "valid repair did not continue",
1501        ));
1502    }
1503    let AgentRunStep::CallTools { calls } = repaired.next_step()? else {
1504        return Err(ScenarioError::contract(
1505            SCENARIO,
1506            "valid repair did not produce pending tool execution",
1507        ));
1508    };
1509    let repaired_call = calls.first();
1510    if calls.len() != 1
1511        || !repaired_call.is_some_and(|call| {
1512            call.tool_call.function.name == CountingSum::NAME && call.preresolved_result.is_none()
1513        })
1514    {
1515        return Err(ScenarioError::contract(
1516            SCENARIO,
1517            format!("repaired pending calls were incorrect: {calls:?}"),
1518        ));
1519    }
1520
1521    let mut skipped = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1522    if !matches!(
1523        skipped.resolve_invalid_tool_call(InvalidToolCallAction::skip("disabled for this turn"))?,
1524        ModelTurnOutcome::Continue { .. }
1525    ) {
1526        return Err(ScenarioError::contract(SCENARIO, "skip did not continue"));
1527    }
1528    let AgentRunStep::CallTools { calls } = skipped.next_step()? else {
1529        return Err(ScenarioError::contract(
1530            SCENARIO,
1531            "skip did not produce a pre-resolved pending call",
1532        ));
1533    };
1534    let skipped_is_preresolved = match calls.first() {
1535        Some(call) => call.preresolved_result.is_some(),
1536        None => false,
1537    };
1538    if calls.len() != 1 || !skipped_is_preresolved {
1539        return Err(ScenarioError::contract(
1540            SCENARIO,
1541            format!("skipped pending calls were incorrect: {calls:?}"),
1542        ));
1543    }
1544    if add_calls.load(Ordering::SeqCst) != 0 || sum_calls.load(Ordering::SeqCst) != 0 {
1545        return Err(ScenarioError::contract(
1546            SCENARIO,
1547            "recovery scenario executed a tool body",
1548        ));
1549    }
1550
1551    Ok(ScenarioReport {
1552        name: SCENARIO,
1553        tool_calls: emitted,
1554        prompt_tokens: turn.usage.input_tokens,
1555        generated_tokens: turn.usage.output_tokens,
1556        history_messages: 2,
1557        duration: started.elapsed(),
1558        response: "fail, retry, repair, rejected repair, and skip passed".to_string(),
1559    })
1560}
1561
1562/// Exercises chained argument/result rewrites and a first-turn-only request
1563/// patch. Completion proves `tool_choice=Required` did not leak to turn two.
1564pub async fn hook_rewrites_and_request_patch<M, F>(
1565    model: M,
1566    configure: F,
1567) -> Result<ScenarioReport, ScenarioError>
1568where
1569    M: CompletionModel + 'static,
1570    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1571{
1572    const SCENARIO: &str = "hook_rewrites_and_request_patch";
1573    let started = Instant::now();
1574    let calls = Arc::new(AtomicUsize::new(0));
1575    let observed = ObserveArguments::default();
1576    let observed_probe = observed.clone();
1577    let agent = configure(AgentBuilder::new(model))
1578        .preamble("Use add for arithmetic and report only the tool result.")
1579        .temperature(0.0)
1580        .tool(CountingAdd(calls.clone()))
1581        .default_max_turns(3)
1582        .build();
1583    let response = agent
1584        .prompt("Use add once for x=1 and y=1, then report what the tool returns.")
1585        .max_turns(3)
1586        .add_hook(FirstTurnPatch(
1587            RequestPatch::new()
1588                .active_tools([CountingAdd::NAME])
1589                .tool_choice(ToolChoice::Required),
1590        ))
1591        .add_hook(RewriteArgument {
1592            key: "x",
1593            value: serde_json::json!(7),
1594        })
1595        .add_hook(RewriteArgument {
1596            key: "y",
1597            value: serde_json::json!(8),
1598        })
1599        .add_hook(observed)
1600        .add_hook(ReplaceResult("portable-redacted"))
1601        .add_hook(WrapResult)
1602        .extended_details()
1603        .await?;
1604    let observations = lock_recover(&observed_probe.0).clone();
1605    validate_rewritten_arguments(
1606        SCENARIO,
1607        &observations,
1608        &serde_json::json!({ "x": 7, "y": 8 }),
1609    )?;
1610    let messages = response.messages.as_deref().ok_or_else(|| {
1611        ScenarioError::contract(SCENARIO, "extended hook run omitted message history")
1612    })?;
1613    let results = messages
1614        .iter()
1615        .flat_map(tool_result_values)
1616        .collect::<Vec<_>>();
1617    if calls.load(Ordering::SeqCst) != 1
1618        || !results
1619            .iter()
1620            .any(|value| value == &serde_json::json!("[portable-redacted]"))
1621        || response.completion_calls.len() != 2
1622    {
1623        return Err(ScenarioError::contract(
1624            SCENARIO,
1625            format!(
1626                "calls={}, completion_calls={}, results={results:?}, output={:?}",
1627                calls.load(Ordering::SeqCst),
1628                response.completion_calls.len(),
1629                response.output
1630            ),
1631        ));
1632    }
1633    report_from_response(SCENARIO, started, 1, response)
1634}
1635
1636/// Exercises post-execution cancellation and max-turn diagnostics through the
1637/// public agent driver using real model-emitted calls.
1638pub async fn cancellation_and_max_turns<M, F>(
1639    model: M,
1640    configure: F,
1641) -> Result<ScenarioReport, ScenarioError>
1642where
1643    M: CompletionModel + Clone + 'static,
1644    F: Fn(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1645{
1646    const SCENARIO: &str = "cancellation_and_max_turns";
1647    const REASON: &str = "portable result veto";
1648    let started = Instant::now();
1649    let cancelled_calls = Arc::new(AtomicUsize::new(0));
1650    let cancelled_agent = configure(AgentBuilder::new(model.clone()))
1651        .preamble("Use add for arithmetic; never calculate by hand.")
1652        .temperature(0.0)
1653        .tool(CountingAdd(cancelled_calls.clone()))
1654        .build();
1655    let cancelled = match cancelled_agent
1656        .prompt("Use add once to compute x=20 plus y=22.")
1657        .max_turns(2)
1658        .add_hook(StopAfterResult(REASON))
1659        .await
1660    {
1661        Err(error) => error,
1662        Ok(output) => {
1663            return Err(ScenarioError::contract(
1664                SCENARIO,
1665                format!("result cancellation unexpectedly completed: {output:?}"),
1666            ));
1667        }
1668    };
1669    validate_cancelled_failure(&cancelled, REASON, CountingAdd::NAME)?;
1670
1671    let max_turn_calls = Arc::new(AtomicUsize::new(0));
1672    let max_turn_agent = configure(AgentBuilder::new(model))
1673        .preamble("Use add for arithmetic; never calculate by hand.")
1674        .temperature(0.0)
1675        .tool(CountingAdd(max_turn_calls.clone()))
1676        .build();
1677    let max_turn = match max_turn_agent
1678        .prompt("Use add once to compute x=20 plus y=22, then report the result.")
1679        .max_turns(1)
1680        .await
1681    {
1682        Err(error) => error,
1683        Ok(output) => {
1684            return Err(ScenarioError::contract(
1685                SCENARIO,
1686                format!("one-turn budget unexpectedly completed: {output:?}"),
1687            ));
1688        }
1689    };
1690    validate_max_turns_failure(&max_turn, 1)?;
1691    let cancelled_count = cancelled_calls.load(Ordering::SeqCst);
1692    let max_turn_count = max_turn_calls.load(Ordering::SeqCst);
1693    if cancelled_count != 1 || max_turn_count != 1 {
1694        return Err(ScenarioError::contract(
1695            SCENARIO,
1696            format!("cancelled executions={cancelled_count}, max-turn executions={max_turn_count}"),
1697        ));
1698    }
1699    Ok(ScenarioReport {
1700        name: SCENARIO,
1701        tool_calls: cancelled_count + max_turn_count,
1702        prompt_tokens: 0,
1703        generated_tokens: 0,
1704        history_messages: 4,
1705        duration: started.elapsed(),
1706        response: "post-result cancellation and max-turn diagnostics passed".to_string(),
1707    })
1708}
1709
1710/// Runs the portable optional-argument tool scenario.
1711///
1712/// `configure` is deliberately outside the scenario so a provider suite can
1713/// attach transport-only settings without putting them into the shared model
1714/// contract.
1715pub async fn optional_argument<M, F>(
1716    model: M,
1717    configure: F,
1718) -> Result<ScenarioReport, ScenarioError>
1719where
1720    M: CompletionModel + 'static,
1721    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1722{
1723    let calls = Arc::new(AtomicUsize::new(0));
1724    let started = Instant::now();
1725    let agent = configure(AgentBuilder::new(model))
1726        .preamble("Use the repeat_text tool whenever asked to repeat text.")
1727        .tool(RepeatTool {
1728            calls: calls.clone(),
1729        })
1730        .default_max_turns(4)
1731        .build();
1732    let result = agent
1733        .prompt(
1734            "Use the repeat_text tool to repeat the word \"banana\" 3 times, then show me the exact result.",
1735        )
1736        .extended_details()
1737        .await?;
1738    let response = result.output.clone();
1739    let tool_calls = calls.load(Ordering::SeqCst);
1740    if tool_calls == 0
1741        || response.matches("banana").count() < 1
1742        || !has_tool_roundtrip(result.messages.as_deref())
1743    {
1744        return Err(ScenarioError::contract(
1745            "optional_argument",
1746            format!("calls={tool_calls}, response={response:?}"),
1747        ));
1748    }
1749    report_from_response("optional_argument", started, tool_calls, result)
1750}
1751
1752/// Runs a portable two-tool sequential arithmetic scenario.
1753pub async fn sequential_tools<M, F>(model: M, configure: F) -> Result<ScenarioReport, ScenarioError>
1754where
1755    M: CompletionModel + 'static,
1756    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1757{
1758    let add_calls = Arc::new(AtomicUsize::new(0));
1759    let multiply_calls = Arc::new(AtomicUsize::new(0));
1760    let started = Instant::now();
1761    let agent = configure(AgentBuilder::new(model))
1762        .preamble(
1763            "You are a calculator. Use the add and multiply tools for arithmetic; never compute by hand.",
1764        )
1765        .tool(AddTool(add_calls.clone()))
1766        .tool(MultiplyTool(multiply_calls.clone()))
1767        .default_max_turns(6)
1768        .build();
1769    let result = agent
1770        .prompt(
1771            "Compute (4 + 6) * 2. First call the add tool, then call the multiply tool on the result. Tell me the final number.",
1772        )
1773        .extended_details()
1774        .await?;
1775    let response = result.output.clone();
1776    let add = add_calls.load(Ordering::SeqCst);
1777    let multiply = multiply_calls.load(Ordering::SeqCst);
1778    if add == 0
1779        || multiply == 0
1780        || !response.contains("20")
1781        || !has_tool_roundtrip(result.messages.as_deref())
1782    {
1783        return Err(ScenarioError::contract(
1784            "sequential_tools",
1785            format!("add={add}, multiply={multiply}, response={response:?}"),
1786        ));
1787    }
1788    report_from_response("sequential_tools", started, add + multiply, result)
1789}
1790
1791/// Runs a tool through Rig's multi-turn streaming agent driver.
1792pub async fn streaming_tool<M, F>(model: M, configure: F) -> Result<ScenarioReport, ScenarioError>
1793where
1794    M: CompletionModel + 'static,
1795    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1796{
1797    let calls = Arc::new(AtomicUsize::new(0));
1798    let started = Instant::now();
1799    let agent = configure(AgentBuilder::new(model))
1800        .preamble("Use the add tool for arithmetic; do not calculate by hand.")
1801        .tool(AddTool(calls.clone()))
1802        .default_max_turns(4)
1803        .build();
1804    let mut stream = agent
1805        .stream_prompt("Use add to calculate 17 + 25, then state the final number.")
1806        .max_turns(4)
1807        .await;
1808    let mut final_response = None;
1809    let mut final_count = 0_usize;
1810    let mut completion_usage = crate::completion::Usage::new();
1811    let mut streamed_call_ids = Vec::new();
1812    let mut streamed_result_ids = Vec::new();
1813    while let Some(item) = stream.next().await {
1814        match item? {
1815            MultiTurnStreamItem::StreamAssistantItem(
1816                crate::streaming::StreamedAssistantContent::ToolCall {
1817                    internal_call_id, ..
1818                },
1819            ) => streamed_call_ids.push(internal_call_id),
1820            MultiTurnStreamItem::StreamUserItem(
1821                crate::streaming::StreamedUserContent::ToolResult {
1822                    internal_call_id, ..
1823                },
1824            ) => streamed_result_ids.push(internal_call_id),
1825            MultiTurnStreamItem::CompletionCall(call) => completion_usage += call.usage,
1826            MultiTurnStreamItem::FinalResponse(response) => {
1827                final_count += 1;
1828                final_response = Some(response);
1829            }
1830            MultiTurnStreamItem::StreamAssistantItem(_)
1831            | MultiTurnStreamItem::ToolExecutionCommitted { .. }
1832            | MultiTurnStreamItem::ModelTurnRetried { .. } => {}
1833        }
1834    }
1835    let result = final_response.ok_or_else(|| {
1836        ScenarioError::contract("streaming_tool", "stream produced no final response")
1837    })?;
1838    let response = result.output.clone();
1839    let history_messages = result.messages.as_ref().map_or(0, Vec::len);
1840    let tool_calls = calls.load(Ordering::SeqCst);
1841    streamed_call_ids.sort();
1842    streamed_result_ids.sort();
1843    let correlated_stream =
1844        !streamed_call_ids.is_empty() && streamed_call_ids == streamed_result_ids;
1845    let correlated_history = result
1846        .messages
1847        .as_deref()
1848        .is_some_and(|messages| validate_tool_correlation("streaming_tool", messages).is_ok());
1849    if tool_calls == 0
1850        || !response.contains("42")
1851        || history_messages < 4
1852        || final_count != 1
1853        || !correlated_stream
1854        || !correlated_history
1855        || completion_usage != result.usage
1856        || result.completion_calls.is_empty()
1857    {
1858        return Err(ScenarioError::contract(
1859            "streaming_tool",
1860            format!(
1861                "calls={tool_calls}, final_count={final_count}, streamed_call_ids={streamed_call_ids:?}, streamed_result_ids={streamed_result_ids:?}, completion_usage={completion_usage:?}, final_usage={:?}, history_messages={history_messages}, response={response:?}",
1862                result.usage
1863            ),
1864        ));
1865    }
1866    if let Some(messages) = result.messages.as_deref() {
1867        validate_protocol_hygiene(
1868            "streaming_tool",
1869            &response,
1870            messages,
1871            &["<tool_call>", "</tool_call>", "<think>", "</think>"],
1872        )?;
1873    }
1874    Ok(ScenarioReport {
1875        name: "streaming_tool",
1876        tool_calls,
1877        prompt_tokens: result.usage.input_tokens,
1878        generated_tokens: result.usage.output_tokens,
1879        history_messages,
1880        duration: started.elapsed(),
1881        response,
1882    })
1883}
1884
1885/// Runs a normal tool followed by Rig's synthetic structured-output tool.
1886pub async fn structured_after_tool<M, F>(
1887    model: M,
1888    configure: F,
1889) -> Result<ScenarioReport, ScenarioError>
1890where
1891    M: CompletionModel + 'static,
1892    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1893{
1894    let calls = Arc::new(AtomicUsize::new(0));
1895    let started = Instant::now();
1896    let agent = configure(AgentBuilder::new(model))
1897        .preamble(
1898            "Use add for arithmetic, then finish by calling the structured output tool exactly once.",
1899        )
1900        .output_schema::<ArithmeticResult>()
1901        .output_mode(OutputMode::Tool)
1902        .tool(AddTool(calls.clone()))
1903        .default_max_turns(5)
1904        .build();
1905    let result = agent
1906        .prompt("Use add to calculate 19 + 23. Return answer=42 and a short optional explanation.")
1907        .extended_details()
1908        .await?;
1909    let response = result.output.clone();
1910    let parsed: ArithmeticResult = serde_json::from_str(&response)?;
1911    let tool_calls = calls.load(Ordering::SeqCst);
1912    if tool_calls == 0 || parsed.answer != 42 || !has_tool_roundtrip(result.messages.as_deref()) {
1913        return Err(ScenarioError::contract(
1914            "structured_after_tool",
1915            format!("calls={tool_calls}, response={response:?}"),
1916        ));
1917    }
1918    let _ = parsed.explanation;
1919    report_from_response("structured_after_tool", started, tool_calls + 1, result)
1920}
1921
1922/// Runs all portable tool-choice modes directly against a completion model.
1923pub async fn tool_choice_modes<M>(model: M) -> Result<ScenarioReport, ScenarioError>
1924where
1925    M: CompletionModel + 'static,
1926{
1927    let definition = |name: &str| ToolDefinition {
1928        name: name.to_string(),
1929        description: format!("Return the supplied integer using {name}."),
1930        parameters: serde_json::json!({
1931            "type": "object",
1932            "properties": {"value": {"type": "integer"}},
1933            "required": ["value"]
1934        }),
1935    };
1936    let tools = vec![definition("alpha"), definition("beta")];
1937    let started = Instant::now();
1938    let none = model
1939        .completion(
1940            model
1941                .completion_request("Answer with only the number 4. Do not call a function.")
1942                .tools(tools.clone())
1943                .tool_choice(ToolChoice::None)
1944                .temperature(0.0)
1945                .max_tokens(64)
1946                .build(),
1947        )
1948        .await?;
1949    if none
1950        .choice
1951        .iter()
1952        .any(|item| matches!(item, AssistantContent::ToolCall(_)))
1953    {
1954        return Err(ScenarioError::contract(
1955            "tool_choice_modes",
1956            "tool_choice none emitted a tool call",
1957        ));
1958    }
1959
1960    let required = model
1961        .completion(
1962            model
1963                .completion_request("Call alpha with value 7.")
1964                .tools(tools.clone())
1965                .tool_choice(ToolChoice::Required)
1966                .temperature(0.0)
1967                .max_tokens(96)
1968                .build(),
1969        )
1970        .await?;
1971    let required_calls = required
1972        .choice
1973        .iter()
1974        .filter(|item| matches!(item, AssistantContent::ToolCall(_)))
1975        .count();
1976    if required_calls == 0 {
1977        return Err(ScenarioError::contract(
1978            "tool_choice_modes",
1979            "tool_choice required emitted no tool call",
1980        ));
1981    }
1982
1983    let specific = model
1984        .completion(
1985            model
1986                .completion_request("Call beta with value 9.")
1987                .tools(tools)
1988                .tool_choice(ToolChoice::Specific {
1989                    function_names: vec!["beta".to_string()],
1990                })
1991                .temperature(0.0)
1992                .max_tokens(96)
1993                .build(),
1994        )
1995        .await?;
1996    let specific_calls = specific
1997        .choice
1998        .iter()
1999        .filter_map(|item| match item {
2000            AssistantContent::ToolCall(call) => Some(call),
2001            _ => None,
2002        })
2003        .collect::<Vec<_>>();
2004    if specific_calls.is_empty()
2005        || specific_calls
2006            .iter()
2007            .any(|call| call.function.name != "beta")
2008    {
2009        return Err(ScenarioError::contract(
2010            "tool_choice_modes",
2011            "specific tool choice did not select only beta",
2012        ));
2013    }
2014
2015    Ok(ScenarioReport {
2016        name: "tool_choice_modes",
2017        tool_calls: required_calls + specific_calls.len(),
2018        prompt_tokens: none.usage.input_tokens
2019            + required.usage.input_tokens
2020            + specific.usage.input_tokens,
2021        generated_tokens: none.usage.output_tokens
2022            + required.usage.output_tokens
2023            + specific.usage.output_tokens,
2024        history_messages: 0,
2025        duration: started.elapsed(),
2026        response: "none, required, and specific modes passed".to_string(),
2027    })
2028}
2029
2030/// Runs a streamed real-tool turn followed by Rig's synthetic output tool.
2031pub async fn streaming_structured_after_tool<M, F>(
2032    model: M,
2033    configure: F,
2034) -> Result<ScenarioReport, ScenarioError>
2035where
2036    M: CompletionModel + 'static,
2037    F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
2038{
2039    let calls = Arc::new(AtomicUsize::new(0));
2040    let started = Instant::now();
2041    let agent = configure(AgentBuilder::new(model))
2042        .preamble(
2043            "Use add for arithmetic, then finish by calling the structured output tool exactly once.",
2044        )
2045        .output_schema::<ArithmeticResult>()
2046        .output_mode(OutputMode::Tool)
2047        .tool(AddTool(calls.clone()))
2048        .default_max_turns(5)
2049        .build();
2050    let mut stream = agent
2051        .stream_prompt(
2052            "Use add to calculate 19 + 23. Return answer=42 and a short optional explanation.",
2053        )
2054        .max_turns(5)
2055        .await;
2056    let mut final_response = None;
2057    let mut final_count = 0_usize;
2058    while let Some(item) = stream.next().await {
2059        if let MultiTurnStreamItem::FinalResponse(response) = item? {
2060            final_count += 1;
2061            final_response = Some(response);
2062        }
2063    }
2064    let result = final_response.ok_or_else(|| {
2065        ScenarioError::contract(
2066            "streaming_structured_after_tool",
2067            "stream produced no final response",
2068        )
2069    })?;
2070    let parsed: ArithmeticResult = serde_json::from_str(&result.output)?;
2071    let calls = calls.load(Ordering::SeqCst);
2072    if calls == 0
2073        || final_count != 1
2074        || parsed.answer != 42
2075        || !has_tool_roundtrip(result.messages.as_deref())
2076    {
2077        return Err(ScenarioError::contract(
2078            "streaming_structured_after_tool",
2079            format!(
2080                "calls={calls}, final_count={final_count}, response={:?}",
2081                result.output
2082            ),
2083        ));
2084    }
2085    report_from_response(
2086        "streaming_structured_after_tool",
2087        started,
2088        calls + 1,
2089        result,
2090    )
2091}
2092
2093#[cfg(test)]
2094mod tests {
2095    use super::*;
2096    use crate::{
2097        completion::Usage,
2098        test_utils::{MockCompletionModel, MockResponse, MockStreamEvent, MockTurn},
2099    };
2100    use rig_core::{
2101        OneOrMany,
2102        message::{ToolCall, ToolFunction},
2103    };
2104
2105    fn tool_call(id: &str, name: &str, arguments: serde_json::Value) -> AssistantContent {
2106        AssistantContent::ToolCall(ToolCall::new(
2107            id.to_string(),
2108            ToolFunction::new(name.to_string(), arguments),
2109        ))
2110    }
2111
2112    fn usage(input: u64, output: u64) -> Usage {
2113        Usage {
2114            input_tokens: input,
2115            output_tokens: output,
2116            total_tokens: input + output,
2117            ..Usage::new()
2118        }
2119    }
2120
2121    fn fixture_contract(condition: bool, details: &str) -> Result<(), ScenarioError> {
2122        if condition {
2123            Ok(())
2124        } else {
2125            Err(ScenarioError::contract("test_fixture", details))
2126        }
2127    }
2128
2129    #[tokio::test]
2130    async fn parallel_contract_validates_batch_and_correlation() -> Result<(), ScenarioError> {
2131        let first = MockTurn::from_contents([
2132            tool_call("call_add", "add", serde_json::json!({"x": 3, "y": 4})),
2133            tool_call(
2134                "call_subtract",
2135                "subtract",
2136                serde_json::json!({"x": 10, "y": 2}),
2137            ),
2138        ])
2139        .map_err(|error| ScenarioError::contract("test_fixture", error.to_string()))?;
2140        let report = parallel_tools(
2141            MockCompletionModel::new([first, MockTurn::text("7 and 8")]),
2142            |builder| builder,
2143            Some(1),
2144        )
2145        .await?;
2146        fixture_contract(report.tool_calls == 2, "parallel tool-call count")?;
2147        fixture_contract(report.history_messages >= 4, "parallel history length")?;
2148        Ok(())
2149    }
2150
2151    #[tokio::test]
2152    async fn zero_argument_and_output_serialization_contracts_pass() -> Result<(), ScenarioError> {
2153        let zero = zero_argument_tool(
2154            MockCompletionModel::new([
2155                MockTurn::tool_call("ping_call", "ping", serde_json::json!({})),
2156                MockTurn::text(PING_OUTPUT),
2157            ]),
2158            |builder| builder,
2159        )
2160        .await?;
2161        fixture_contract(zero.tool_calls == 1, "zero-argument call count")?;
2162
2163        let first = MockTurn::from_contents([
2164            tool_call("motto_call", "fetch_motto", serde_json::json!({})),
2165            tool_call("config_call", "fetch_config", serde_json::json!({})),
2166        ])
2167        .map_err(|error| ScenarioError::contract("test_fixture", error.to_string()))?;
2168        let serialized = tool_output_serialization(
2169            MockCompletionModel::new([first, MockTurn::text("summary")]),
2170            |builder| builder,
2171        )
2172        .await?;
2173        fixture_contract(serialized.tool_calls == 2, "serialized-output call count")?;
2174        Ok(())
2175    }
2176
2177    #[tokio::test]
2178    async fn complex_arguments_preserve_nested_unicode_and_escapes() -> Result<(), ScenarioError> {
2179        let arguments = serde_json::json!({
2180            "profile": {"name": "Zoë \"Z\"", "tags": ["rust", "東京"]},
2181            "mode": "careful",
2182            "note": "line one\nline two",
2183            "quote": "path C:\\tmp and \"quoted\""
2184        });
2185        let report = complex_tool_arguments(
2186            MockCompletionModel::new([
2187                MockTurn::tool_call("profile_call", "store_profile", arguments),
2188                MockTurn::text("stored"),
2189            ]),
2190            |builder| builder,
2191        )
2192        .await?;
2193        fixture_contract(report.tool_calls == 1, "complex-argument call count")?;
2194        Ok(())
2195    }
2196
2197    #[tokio::test]
2198    async fn extraction_contract_requires_fields_and_usage() -> Result<(), ScenarioError> {
2199        let report = structured_extraction(MockCompletionModel::new([MockTurn::tool_call(
2200            "submit_call",
2201            "submit",
2202            serde_json::json!({
2203                "first_name": "Ada",
2204                "last_name": "Lovelace",
2205                "job": "mathematician"
2206            }),
2207        )
2208        .with_usage(usage(20, 5))]))
2209        .await?;
2210        fixture_contract(report.prompt_tokens == 20, "extraction input usage")?;
2211        fixture_contract(report.generated_tokens == 5, "extraction output usage")?;
2212        Ok(())
2213    }
2214
2215    #[tokio::test]
2216    async fn streaming_contract_checks_events_history_and_usage() -> Result<(), ScenarioError> {
2217        let model = MockCompletionModel::from_stream_turns([
2218            vec![
2219                MockStreamEvent::tool_call(
2220                    "add_call",
2221                    "add",
2222                    serde_json::json!({"a": 17, "b": 25}),
2223                ),
2224                MockStreamEvent::FinalResponse(MockResponse::with_usage(usage(10, 2))),
2225            ],
2226            vec![
2227                MockStreamEvent::text("42"),
2228                MockStreamEvent::FinalResponse(MockResponse::with_usage(usage(14, 1))),
2229            ],
2230        ]);
2231        let report = streaming_tool(model, |builder| builder).await?;
2232        fixture_contract(report.prompt_tokens == 24, "streaming input usage")?;
2233        fixture_contract(report.generated_tokens == 3, "streaming output usage")?;
2234        Ok(())
2235    }
2236
2237    #[tokio::test]
2238    async fn invalid_recovery_paths_do_not_execute_tools() -> Result<(), ScenarioError> {
2239        let report = invalid_tool_recovery(
2240            MockCompletionModel::new([MockTurn::tool_call(
2241                "invalid-add",
2242                "add",
2243                serde_json::json!({ "x": 2, "y": 3 }),
2244            )]),
2245            |builder| builder,
2246        )
2247        .await?;
2248        fixture_contract(report.tool_calls == 1, "recovery source call count")?;
2249        Ok(())
2250    }
2251
2252    #[tokio::test]
2253    async fn hook_rewrites_chain_and_request_patch_is_turn_local() -> Result<(), ScenarioError> {
2254        let report = hook_rewrites_and_request_patch(
2255            MockCompletionModel::new([
2256                MockTurn::tool_call("hook-add", "add", serde_json::json!({ "x": 1, "y": 1 })),
2257                MockTurn::text("[portable-redacted]"),
2258            ]),
2259            |builder| builder,
2260        )
2261        .await?;
2262        fixture_contract(report.tool_calls == 1, "hook execution count")?;
2263        Ok(())
2264    }
2265
2266    #[tokio::test]
2267    async fn cancellation_and_max_turn_controls_retain_diagnostics() -> Result<(), ScenarioError> {
2268        let report = cancellation_and_max_turns(
2269            MockCompletionModel::new([
2270                MockTurn::tool_call("cancel-add", "add", serde_json::json!({ "x": 20, "y": 22 })),
2271                MockTurn::tool_call("budget-add", "add", serde_json::json!({ "x": 20, "y": 22 })),
2272            ]),
2273            |builder| builder,
2274        )
2275        .await?;
2276        fixture_contract(report.tool_calls == 2, "run-control execution count")?;
2277        Ok(())
2278    }
2279
2280    #[test]
2281    fn typed_validators_reject_bad_structured_output_and_protocol_leaks() {
2282        let invalid = decode_structured_output::<ConfigOutput>("invalid_json", "not json");
2283        assert!(matches!(invalid, Err(ScenarioError::Contract { .. })));
2284
2285        let messages = vec![Message::Assistant {
2286            id: None,
2287            content: OneOrMany::one(AssistantContent::text("visible <tool_call>")),
2288        }];
2289        let hygiene = validate_protocol_hygiene(
2290            "protocol_hygiene",
2291            "visible <tool_call>",
2292            &messages,
2293            &["<tool_call>"],
2294        );
2295        assert!(matches!(hygiene, Err(ScenarioError::Contract { .. })));
2296    }
2297
2298    #[test]
2299    fn invalid_tool_diagnostics_require_rejected_call_history() {
2300        let history = vec![Message::Assistant {
2301            id: None,
2302            content: OneOrMany::one(tool_call(
2303                "bad_call",
2304                "missing",
2305                serde_json::json!({"value": 1}),
2306            )),
2307        }];
2308        let error = PromptError::UnknownToolCall {
2309            tool_name: "missing".to_string(),
2310            available_tools: vec!["add".to_string()],
2311            allowed_tools: Vec::new(),
2312            chat_history: Box::new(history),
2313        };
2314        assert!(validate_unknown_tool_failure(&error, "missing", &[]).is_ok());
2315        assert!(validate_unknown_tool_failure(&error, "other", &[]).is_err());
2316    }
2317}