Skip to main content

ai_agents_eval/
fixtures.rs

1use std::collections::HashMap;
2use std::fs::OpenOptions;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tokio::net::TcpListener;
10use tokio::task::JoinHandle;
11
12use ai_agents_core::{
13    ChatMessage, LLMChunk, LLMConfig, LLMError, LLMFeature, LLMProvider, LLMResponse, Tool,
14    ToolExecutionContext, ToolPolicyBindings, ToolResult,
15};
16use ai_agents_llm::providers::{ProviderType, UnifiedLLMProvider};
17use ai_agents_llm::{FinishReason, LLMRegistry};
18use ai_agents_runtime::spec::AgentSpec;
19use ai_agents_tools::{
20    CommandResponse, DiagnosticItem, DiagnosticsProvider, StaticCommandRunner,
21    StaticDiagnosticsProvider, ToolRegistry, UnavailableDiagnosticsProvider,
22    create_builtin_registry,
23};
24use async_trait::async_trait;
25use futures::Stream;
26use parking_lot::Mutex;
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use sha2::{Digest, Sha256};
30
31use crate::evidence::{ToolExecutionRecord, ToolExecutionSource};
32use crate::{EvalError, Result};
33
34/// Fixture configuration used to replace external dependencies during eval.
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct FixturesConfig {
37    /// Runtime or fixture context value.
38    #[serde(default)]
39    pub context: Option<Value>,
40    /// JSON context file resolved relative to the suite file.
41    #[serde(default)]
42    pub context_file: Option<PathBuf>,
43    /// Mock tool definitions keyed by tool ID.
44    #[serde(default)]
45    pub tools: HashMap<String, ToolMockConfig>,
46    /// Optional LLM alias or provider used for judge calls.
47    #[serde(default)]
48    pub llm: LlmFixtureConfig,
49    /// Optional local HTTP mock server configuration.
50    #[serde(default)]
51    pub mock_server: Option<MockServerConfig>,
52    /// Optional mocked diagnostics returned by the diagnostics tool.
53    #[serde(default)]
54    pub diagnostics: Option<DiagnosticsFixtureConfig>,
55    /// Optional mocked command-runner responses for the command tool.
56    #[serde(default)]
57    pub commands: Option<CommandsFixtureConfig>,
58}
59
60/// Diagnostics fixture configuration for host-backed diagnostics.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DiagnosticsFixtureConfig {
63    /// Whether the provider is available.
64    #[serde(default = "default_true")]
65    pub available: bool,
66    /// Deterministic diagnostics returned by the provider.
67    #[serde(default)]
68    pub items: Vec<DiagnosticItem>,
69}
70
71/// Command-runner fixture configuration for process-backed validation tools.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CommandsFixtureConfig {
74    /// Whether the command runner is available.
75    #[serde(default = "default_true")]
76    pub available: bool,
77    /// Deterministic exact-argv command responses.
78    #[serde(default)]
79    pub entries: Vec<CommandFixtureEntry>,
80}
81
82/// One exact-argv mocked command response.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CommandFixtureEntry {
85    /// Full argv vector used for exact matching.
86    #[serde(default)]
87    pub argv: Vec<String>,
88    /// Mocked command response returned to the tool.
89    #[serde(default)]
90    pub response: CommandResponse,
91}
92
93/// Static output configuration for an eval mock tool.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ToolMockConfig {
96    /// Whether the operation succeeded.
97    #[serde(default = "default_true")]
98    pub success: bool,
99    /// Directory where output artifacts are written.
100    #[serde(default)]
101    pub output: Value,
102}
103
104impl Default for ToolMockConfig {
105    fn default() -> Self {
106        Self {
107            success: true,
108            output: Value::Null,
109        }
110    }
111}
112
113/// LLM fixture mode and data used by the eval runner.
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct LlmFixtureConfig {
116    /// LLM fixture mode used for configured aliases.
117    #[serde(default)]
118    pub mode: LlmFixtureMode,
119    /// Optional cassette JSONL file for replay or record mode.
120    #[serde(default)]
121    pub cassette: Option<PathBuf>,
122    /// Ordered text responses used by mock mode and fallback replay.
123    #[serde(default)]
124    pub responses: Vec<String>,
125    /// Per-LLM alias ordered responses for deterministic multi-branch evals.
126    #[serde(default)]
127    pub responses_by_alias: HashMap<String, Vec<String>>,
128    /// Per-LLM alias errors for deterministic failure-path evals.
129    #[serde(default)]
130    pub errors_by_alias: HashMap<String, String>,
131    /// Per-LLM alias delay in milliseconds for deterministic branch ordering.
132    #[serde(default)]
133    pub delays_by_alias: HashMap<String, u64>,
134}
135
136/// LLM fixture strategy used while building eval providers.
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum LlmFixtureMode {
140    #[default]
141    Real,
142    Mock,
143    Replay,
144    Record,
145}
146
147/// Local HTTP mock server configuration for eval scenarios.
148#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149pub struct MockServerConfig {
150    /// Whether this feature is enabled.
151    #[serde(default)]
152    pub enabled: bool,
153    /// Optional fixed port. Zero or none requests a dynamic port.
154    #[serde(default)]
155    pub port: Option<u16>,
156    /// Route definitions served by the mock server.
157    #[serde(default)]
158    pub routes: Vec<Value>,
159}
160
161/// One route served by the lightweight eval mock server.
162#[derive(Debug, Clone, Deserialize)]
163struct MockRoute {
164    /// HTTP method matched by this route.
165    method: String,
166    /// Path used for file lookup, HTTP routing, or dot-path checks.
167    path: String,
168    /// Final or normalized status value.
169    #[serde(default = "default_status")]
170    status: u16,
171    /// Extra response headers returned by this route.
172    #[serde(default)]
173    headers: HashMap<String, String>,
174    /// JSON or string body returned by this route.
175    #[serde(default)]
176    body: Value,
177}
178
179/// Running mock server handle that stops the server on drop.
180pub struct MockServerHandle {
181    /// Base URL injected into eval context.
182    base_url: String,
183    /// Background accept loop for the mock server.
184    task: JoinHandle<()>,
185}
186
187impl MockServerHandle {
188    pub fn context(&self) -> HashMap<String, Value> {
189        let mut context = HashMap::new();
190        context.insert(
191            "mock_server".to_string(),
192            serde_json::json!({"base_url": self.base_url}),
193        );
194        context
195    }
196}
197
198impl Drop for MockServerHandle {
199    fn drop(&mut self) {
200        self.task.abort();
201    }
202}
203
204/// Shared in-memory log of tool executions for one attempt.
205#[derive(Clone, Default)]
206pub struct RecordingToolLog {
207    /// Wrapped implementation or shared storage.
208    inner: Arc<Mutex<Vec<ToolExecutionRecord>>>,
209}
210
211impl From<&ai_agents_core::ToolCallSource> for ToolExecutionSource {
212    fn from(source: &ai_agents_core::ToolCallSource) -> Self {
213        match source {
214            ai_agents_core::ToolCallSource::Model => Self::Llm,
215            ai_agents_core::ToolCallSource::Skill { .. } => Self::Skill,
216            ai_agents_core::ToolCallSource::StateAction { .. } => Self::StateAction,
217            ai_agents_core::ToolCallSource::Orchestration => Self::Orchestration,
218            ai_agents_core::ToolCallSource::Spawner => Self::Spawner,
219            ai_agents_core::ToolCallSource::EvalFixture => Self::Mock,
220            ai_agents_core::ToolCallSource::Plan { .. }
221            | ai_agents_core::ToolCallSource::Fallback { .. }
222            | ai_agents_core::ToolCallSource::Task
223            | ai_agents_core::ToolCallSource::Manual => Self::Llm,
224        }
225    }
226}
227
228fn state_from_source(source: &ai_agents_core::ToolCallSource) -> Option<String> {
229    match source {
230        ai_agents_core::ToolCallSource::StateAction { state, .. } => state.clone(),
231        _ => None,
232    }
233}
234
235impl RecordingToolLog {
236    pub fn new() -> Self {
237        Self::default()
238    }
239
240    pub fn len(&self) -> usize {
241        self.inner.lock().len()
242    }
243
244    pub fn push(&self, record: ToolExecutionRecord) {
245        self.inner.lock().push(record);
246    }
247
248    /// Record evidence produced by the shared runtime executor.
249    pub fn push_executor_record(&self, record: &ai_agents_core::ToolExecutionRecord) {
250        let output = record.success.then(|| {
251            serde_json::from_str(&record.output).unwrap_or(Value::String(record.output.clone()))
252        });
253        let mut metadata = record.metadata.clone();
254        metadata.insert("executed".to_string(), Value::Bool(record.executed));
255        metadata.insert(
256            "policy".to_string(),
257            serde_json::to_value(&record.policy).unwrap_or(Value::Null),
258        );
259        metadata.insert(
260            "approval".to_string(),
261            serde_json::to_value(&record.approval).unwrap_or(Value::Null),
262        );
263        metadata.insert("timed_out".to_string(), Value::Bool(record.timed_out));
264        metadata.insert("cancelled".to_string(), Value::Bool(record.cancelled));
265        self.push(ToolExecutionRecord {
266            call_id: record.call_id.clone(),
267            tool_id: record.canonical_id.clone(),
268            requested_name: record.requested_name.clone(),
269            source: ToolExecutionSource::from(&record.source),
270            state: state_from_source(&record.source),
271            actor_id: None,
272            arguments_original: record.arguments.clone(),
273            arguments_executed: record.executed_arguments.clone(),
274            executed: record.executed,
275            success: record.success,
276            output,
277            error: (!record.success).then_some(record.output.clone()),
278            metadata: Some(serde_json::to_value(metadata).unwrap_or(Value::Null)),
279            started_at: record.started_at,
280            duration_ms: record.duration_ms,
281            observability_span_id: None,
282        });
283    }
284
285    pub fn records_since(&self, index: usize) -> Vec<ToolExecutionRecord> {
286        self.inner.lock().iter().skip(index).cloned().collect()
287    }
288}
289
290pub fn resolve_fixture_context(
291    config: &FixturesConfig,
292    base_dir: &Path,
293) -> Result<HashMap<String, Value>> {
294    let mut result = HashMap::new();
295    if let Some(path) = &config.context_file {
296        let resolved = resolve_path(base_dir, path);
297        let content = std::fs::read_to_string(&resolved).map_err(|error| {
298            EvalError::Config(format!(
299                "failed to read context_file '{}': {}",
300                resolved.display(),
301                error
302            ))
303        })?;
304        let value: Value = serde_json::from_str(&content).map_err(|error| {
305            EvalError::Config(format!(
306                "failed to parse context_file '{}': {}",
307                resolved.display(),
308                error
309            ))
310        })?;
311        merge_object_into_map(&mut result, value)?;
312    }
313    if let Some(value) = &config.context {
314        merge_object_into_map(&mut result, value.clone())?;
315    }
316    Ok(result)
317}
318
319fn merge_object_into_map(target: &mut HashMap<String, Value>, value: Value) -> Result<()> {
320    let Value::Object(map) = value else {
321        return Err(EvalError::Config(
322            "fixture context must be a JSON object".into(),
323        ));
324    };
325    for (key, value) in map {
326        target.insert(key, value);
327    }
328    Ok(())
329}
330
331fn resolve_path(base_dir: &Path, path: &Path) -> PathBuf {
332    if path.is_absolute() {
333        path.to_path_buf()
334    } else {
335        base_dir.join(path)
336    }
337}
338
339pub async fn start_mock_server(
340    config: Option<&MockServerConfig>,
341) -> Result<Option<MockServerHandle>> {
342    let Some(config) = config else {
343        return Ok(None);
344    };
345    if !config.enabled {
346        return Ok(None);
347    }
348    let routes = config
349        .routes
350        .iter()
351        .cloned()
352        .map(serde_json::from_value::<MockRoute>)
353        .collect::<std::result::Result<Vec<_>, _>>()?;
354    let port = config.port.unwrap_or(0);
355    let listener = TcpListener::bind(("127.0.0.1", port))
356        .await
357        .map_err(|error| EvalError::Runtime(format!("failed to start mock server: {}", error)))?;
358    let addr = listener.local_addr().map_err(|error| {
359        EvalError::Runtime(format!("failed to read mock server addr: {}", error))
360    })?;
361    let base_url = format!("http://{}", addr);
362    let task = tokio::spawn(async move {
363        loop {
364            let Ok((stream, _)) = listener.accept().await else {
365                break;
366            };
367            let routes = routes.clone();
368            tokio::spawn(async move {
369                let _ = handle_mock_connection(stream, routes).await;
370            });
371        }
372    });
373    Ok(Some(MockServerHandle { base_url, task }))
374}
375
376async fn handle_mock_connection(
377    mut stream: tokio::net::TcpStream,
378    routes: Vec<MockRoute>,
379) -> std::io::Result<()> {
380    let mut buffer = vec![0_u8; 8192];
381    let read = stream.read(&mut buffer).await?;
382    let request = String::from_utf8_lossy(&buffer[..read]);
383    let first_line = request.lines().next().unwrap_or_default();
384    let mut parts = first_line.split_whitespace();
385    let method = parts.next().unwrap_or_default();
386    let path = parts.next().unwrap_or_default();
387    let route = routes
388        .iter()
389        .find(|route| route.method.eq_ignore_ascii_case(method) && route.path == path);
390    let (status, headers, body) = if let Some(route) = route {
391        (
392            route.status,
393            route.headers.clone(),
394            mock_body_to_string(&route.body),
395        )
396    } else {
397        (
398            404,
399            HashMap::new(),
400            serde_json::json!({"error":"not found"}).to_string(),
401        )
402    };
403    let reason = match status {
404        200 => "OK",
405        201 => "Created",
406        204 => "No Content",
407        400 => "Bad Request",
408        401 => "Unauthorized",
409        403 => "Forbidden",
410        404 => "Not Found",
411        500 => "Internal Server Error",
412        _ => "OK",
413    };
414    let mut response = format!(
415        "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n",
416        status,
417        reason,
418        body.len()
419    );
420    for (key, value) in headers {
421        response.push_str(&format!("{}: {}\r\n", key, value));
422    }
423    response.push_str("\r\n");
424    response.push_str(&body);
425    stream.write_all(response.as_bytes()).await?;
426    Ok(())
427}
428
429fn mock_body_to_string(body: &Value) -> String {
430    if let Some(text) = body.as_str() {
431        text.to_string()
432    } else {
433        serde_json::to_string(body).unwrap_or_else(|_| "null".to_string())
434    }
435}
436
437pub fn build_tool_registry(
438    fixtures: &FixturesConfig,
439    log: RecordingToolLog,
440) -> Result<ToolRegistry> {
441    let builtin = create_builtin_registry();
442    let mut registry = ToolRegistry::new();
443    let provider: Arc<dyn DiagnosticsProvider> = if let Some(diagnostics) = &fixtures.diagnostics {
444        Arc::new(StaticDiagnosticsProvider::with_availability(
445            diagnostics.items.clone(),
446            diagnostics.available,
447        ))
448    } else {
449        Arc::new(UnavailableDiagnosticsProvider)
450    };
451    builtin.set_diagnostics_provider(provider.clone());
452    registry.set_diagnostics_provider(provider);
453
454    if let Some(commands) = &fixtures.commands {
455        let responses = commands
456            .entries
457            .iter()
458            .map(|entry| (entry.argv.clone(), entry.response.clone()))
459            .collect();
460        let runner = Arc::new(StaticCommandRunner::with_availability(
461            responses,
462            commands.available,
463        ));
464        builtin.set_command_runner(runner.clone());
465        registry.set_command_runner(runner);
466    }
467
468    for (id, mock) in &fixtures.tools {
469        registry
470            .register(Arc::new(RecordingTool::new(
471                Arc::new(MockTool::new(id.clone(), mock.clone())),
472                log.clone(),
473                ToolExecutionSource::Mock,
474            )))
475            .map_err(|error| EvalError::Config(error.to_string()))?;
476    }
477
478    for id in builtin.list_ids() {
479        if fixtures.tools.contains_key(&id) {
480            continue;
481        }
482        if let Some(tool) = builtin.get(&id) {
483            registry
484                .register(Arc::new(RecordingTool::new(
485                    tool,
486                    log.clone(),
487                    ToolExecutionSource::Llm,
488                )))
489                .map_err(|error| EvalError::Config(error.to_string()))?;
490        }
491    }
492
493    Ok(registry)
494}
495
496/// Tool implementation returning a configured eval fixture result.
497struct MockTool {
498    /// Stable identifier for this item.
499    id: String,
500    /// Configuration used by this component.
501    config: ToolMockConfig,
502}
503
504impl MockTool {
505    fn new(id: String, config: ToolMockConfig) -> Self {
506        Self { id, config }
507    }
508}
509
510#[async_trait]
511impl Tool for MockTool {
512    fn id(&self) -> &str {
513        &self.id
514    }
515
516    fn name(&self) -> &str {
517        &self.id
518    }
519
520    fn description(&self) -> &str {
521        "Evaluation mock tool"
522    }
523
524    fn input_schema(&self) -> Value {
525        serde_json::json!({"type": "object"})
526    }
527
528    async fn execute(
529        &self,
530        _args: Value,
531        _ctx: ai_agents_core::ToolExecutionContext,
532    ) -> ToolResult {
533        let output = if self.config.output.is_string() {
534            self.config.output.as_str().unwrap_or_default().to_string()
535        } else {
536            serde_json::to_string(&self.config.output).unwrap_or_else(|_| "null".to_string())
537        };
538        ToolResult {
539            success: self.config.success,
540            output,
541            metadata: None,
542        }
543    }
544}
545
546/// Tool wrapper that records calls before returning the inner result.
547struct RecordingTool {
548    /// Wrapped implementation or shared storage.
549    inner: Arc<dyn Tool>,
550}
551
552impl RecordingTool {
553    fn new(inner: Arc<dyn Tool>, _log: RecordingToolLog, _source: ToolExecutionSource) -> Self {
554        Self { inner }
555    }
556}
557
558#[async_trait]
559impl Tool for RecordingTool {
560    fn id(&self) -> &str {
561        self.inner.id()
562    }
563
564    fn name(&self) -> &str {
565        self.inner.name()
566    }
567
568    fn description(&self) -> &str {
569        self.inner.description()
570    }
571
572    fn input_schema(&self) -> Value {
573        self.inner.input_schema()
574    }
575
576    fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
577        self.inner.safety_metadata()
578    }
579
580    fn classify_call(&self, args: &Value) -> ai_agents_core::ToolCallClassification {
581        self.inner.classify_call(args)
582    }
583
584    fn policy_bindings(&self) -> ToolPolicyBindings {
585        self.inner.policy_bindings()
586    }
587
588    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
589        self.inner.execute(args, ctx).await
590    }
591}
592
593pub fn build_llm_registry(
594    spec: &AgentSpec,
595    fixtures: &LlmFixtureConfig,
596    base_dir: &Path,
597) -> Result<(LLMRegistry, Option<Arc<dyn LLMProvider>>)> {
598    let mut registry = LLMRegistry::new();
599    let aliases = if spec.llms.is_empty() {
600        vec![(
601            "default".to_string(),
602            spec.llm.as_config().cloned().unwrap_or_default(),
603        )]
604    } else {
605        spec.llms
606            .iter()
607            .map(|(alias, config)| (alias.clone(), config.clone()))
608            .collect()
609    };
610
611    let cassette_records = load_cassette_records(fixtures, base_dir)?;
612    let mut judge_provider = None;
613
614    for (alias, config) in aliases {
615        let fixture_responses = load_fixture_responses_for_alias(fixtures, base_dir, &alias)?;
616        let fixture_error = fixtures.errors_by_alias.get(&alias).cloned();
617        let fixture_delay_ms = fixtures.delays_by_alias.get(&alias).copied().unwrap_or(0);
618        let provider = match fixtures.mode {
619            LlmFixtureMode::Mock => Arc::new(SequenceLLMProvider::new(
620                alias.clone(),
621                fixture_responses.clone(),
622                fixture_error,
623                fixture_delay_ms,
624            )) as Arc<dyn LLMProvider>,
625            LlmFixtureMode::Replay => Arc::new(ReplayLLMProvider::new(
626                alias.clone(),
627                config.model.clone(),
628                cassette_records.clone(),
629                fixture_responses.clone(),
630                fixture_delay_ms,
631            )) as Arc<dyn LLMProvider>,
632            LlmFixtureMode::Real => build_real_provider(&config)?,
633            LlmFixtureMode::Record => {
634                let inner = build_real_provider(&config)?;
635                let path = fixtures
636                    .cassette
637                    .as_ref()
638                    .map(|p| resolve_path(base_dir, p))
639                    .unwrap_or_else(|| base_dir.join("llm_cassette.jsonl"));
640                Arc::new(RecordingLLMProvider::new(
641                    inner,
642                    alias.clone(),
643                    config.model.clone(),
644                    path,
645                )) as Arc<dyn LLMProvider>
646            }
647        };
648        if judge_provider.is_none() {
649            judge_provider = Some(provider.clone());
650        }
651        registry.register(alias, provider);
652    }
653
654    let default_alias = spec.llm.get_default_alias();
655    registry.set_default(default_alias);
656    if let Some(router) = spec.llm.get_router_alias() {
657        registry.set_router(router);
658    }
659
660    Ok((registry, judge_provider))
661}
662
663fn build_real_provider(
664    config: &ai_agents_runtime::spec::LLMConfig,
665) -> Result<Arc<dyn LLMProvider>> {
666    use std::str::FromStr;
667    let provider_type = ProviderType::from_str(&config.provider)
668        .map_err(|error| EvalError::Config(error.to_string()))?;
669    let core_config = ai_agents_core::LLMConfig {
670        temperature: Some(config.temperature),
671        max_tokens: Some(config.max_tokens),
672        top_p: config.top_p,
673        top_k: None,
674        frequency_penalty: None,
675        presence_penalty: None,
676        stop_sequences: None,
677        timeout_seconds: config.timeout_seconds,
678        reasoning: config.reasoning,
679        reasoning_effort: config.reasoning_effort.clone(),
680        reasoning_budget_tokens: config.reasoning_budget_tokens,
681        extra: config.extra.clone(),
682    };
683    let base_url = config.base_url.clone().or_else(|| {
684        config
685            .extra
686            .get("base_url")
687            .and_then(Value::as_str)
688            .map(str::to_string)
689    });
690    let api_key = config
691        .api_key_env
692        .as_ref()
693        .and_then(|env| std::env::var(env).ok());
694    let mut provider = UnifiedLLMProvider::from_spec_config(
695        provider_type,
696        &config.model,
697        api_key,
698        base_url,
699        core_config,
700    )
701    .map_err(|error| EvalError::Runtime(error.to_string()))?;
702    if let Some(value) = config.function_calling {
703        provider = provider.with_feature_override(LLMFeature::FunctionCalling, value);
704    }
705    if let Some(value) = config.vision {
706        provider = provider.with_feature_override(LLMFeature::Vision, value);
707    }
708    if let Some(value) = config.json_mode {
709        provider = provider.with_feature_override(LLMFeature::JsonMode, value);
710    }
711    Ok(Arc::new(provider))
712}
713
714fn load_fixture_responses_for_alias(
715    config: &LlmFixtureConfig,
716    base_dir: &Path,
717    alias: &str,
718) -> Result<Vec<LLMResponse>> {
719    let configured = config
720        .responses_by_alias
721        .get(alias)
722        .unwrap_or(&config.responses);
723    let mut responses = Vec::new();
724    for content in configured {
725        responses.push(LLMResponse::new(content.clone(), FinishReason::Stop));
726    }
727    for record in load_cassette_records(config, base_dir)? {
728        if record.alias == alias {
729            responses.push(record.response);
730        }
731    }
732    if responses.is_empty() {
733        responses.push(LLMResponse::new("Mock response", FinishReason::Stop));
734    }
735    Ok(responses)
736}
737
738fn load_cassette_records(
739    config: &LlmFixtureConfig,
740    base_dir: &Path,
741) -> Result<Vec<CassetteRecord>> {
742    let mut records = Vec::new();
743    if let Some(path) = &config.cassette {
744        let resolved = resolve_path(base_dir, path);
745        if resolved.exists() {
746            let content = std::fs::read_to_string(&resolved)?;
747            for line in content.lines().filter(|line| !line.trim().is_empty()) {
748                records.push(serde_json::from_str(line)?);
749            }
750        }
751    }
752    Ok(records)
753}
754
755/// One recorded LLM response used by replay and record modes.
756#[derive(Debug, Clone, Serialize, Deserialize)]
757struct CassetteRecord {
758    /// LLM alias used for this provider or cassette record.
759    alias: String,
760    /// Model or relationship model name.
761    model: String,
762    /// Stable hash of messages and request config.
763    request_hash: String,
764    /// Hash format version.
765    #[serde(default)]
766    request_hash_version: Option<String>,
767    /// Assistant response text or redacted output value.
768    response: LLMResponse,
769}
770
771/// Deterministic LLM provider returning fixture responses in order.
772struct SequenceLLMProvider {
773    /// Ordered text responses used by mock mode and fallback replay.
774    responses: Arc<Mutex<Vec<LLMResponse>>>,
775    /// Zero-based turn index within the scenario.
776    index: Arc<Mutex<usize>>,
777    /// Optional deterministic provider error.
778    error: Option<String>,
779    /// Fixed delay before returning a response.
780    delay_ms: u64,
781}
782
783/// LLM provider replaying cassette records by request hash.
784struct ReplayLLMProvider {
785    /// LLM alias used for this provider or cassette record.
786    alias: String,
787    /// Model or relationship model name.
788    model: String,
789    /// Cassette records available for hash matching.
790    records: Arc<Vec<CassetteRecord>>,
791    /// Ordered text responses used by mock mode and fallback replay.
792    responses: SequenceLLMProvider,
793    /// Fixed delay before returning a replayed response.
794    delay_ms: u64,
795}
796
797impl SequenceLLMProvider {
798    fn new(
799        _name: String,
800        responses: Vec<LLMResponse>,
801        error: Option<String>,
802        delay_ms: u64,
803    ) -> Self {
804        Self {
805            responses: Arc::new(Mutex::new(responses)),
806            index: Arc::new(Mutex::new(0)),
807            error,
808            delay_ms,
809        }
810    }
811
812    async fn wait_if_configured(&self) {
813        if self.delay_ms > 0 {
814            tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
815        }
816    }
817
818    fn next_response(&self) -> LLMResponse {
819        let responses = self.responses.lock();
820        let mut index = self.index.lock();
821        let response = responses
822            .get(*index)
823            .cloned()
824            .or_else(|| responses.last().cloned())
825            .unwrap_or_else(|| LLMResponse::new("Mock response", FinishReason::Stop));
826        if *index + 1 < responses.len() {
827            *index += 1;
828        }
829        response
830    }
831}
832
833impl ReplayLLMProvider {
834    fn new(
835        alias: String,
836        model: String,
837        records: Vec<CassetteRecord>,
838        fallback: Vec<LLMResponse>,
839        delay_ms: u64,
840    ) -> Self {
841        Self {
842            alias,
843            model,
844            records: Arc::new(records),
845            responses: SequenceLLMProvider::new(
846                "replay-fallback".to_string(),
847                fallback,
848                None,
849                delay_ms,
850            ),
851            delay_ms,
852        }
853    }
854
855    async fn wait_if_configured(&self) {
856        if self.delay_ms > 0 {
857            tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
858        }
859    }
860
861    fn response_for(&self, messages: &[ChatMessage], config: Option<&LLMConfig>) -> LLMResponse {
862        let request_hash = hash_request(messages, config);
863        if let Some(record) = self.records.iter().find(|record| {
864            record.alias == self.alias
865                && record.request_hash == request_hash
866                && (record.model == self.model || record.model.is_empty())
867        }) {
868            return record.response.clone();
869        }
870        self.responses.next_response()
871    }
872}
873
874#[async_trait]
875impl LLMProvider for ReplayLLMProvider {
876    async fn complete(
877        &self,
878        messages: &[ChatMessage],
879        config: Option<&LLMConfig>,
880    ) -> std::result::Result<LLMResponse, LLMError> {
881        self.wait_if_configured().await;
882        Ok(self.response_for(messages, config))
883    }
884
885    async fn complete_stream(
886        &self,
887        messages: &[ChatMessage],
888        config: Option<&LLMConfig>,
889    ) -> std::result::Result<
890        Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
891        LLMError,
892    > {
893        self.wait_if_configured().await;
894        let response = self.response_for(messages, config);
895        Ok(Box::new(futures::stream::iter(chunks_from_response(
896            response,
897        ))))
898    }
899
900    fn provider_name(&self) -> &str {
901        "eval-replay"
902    }
903
904    fn supports(&self, feature: LLMFeature) -> bool {
905        matches!(feature, LLMFeature::Streaming | LLMFeature::SystemMessages)
906    }
907}
908
909#[async_trait]
910impl LLMProvider for SequenceLLMProvider {
911    async fn complete(
912        &self,
913        _messages: &[ChatMessage],
914        _config: Option<&LLMConfig>,
915    ) -> std::result::Result<LLMResponse, LLMError> {
916        self.wait_if_configured().await;
917        if let Some(error) = &self.error {
918            return Err(LLMError::API {
919                message: error.clone(),
920                status: None,
921            });
922        }
923        Ok(self.next_response())
924    }
925
926    async fn complete_stream(
927        &self,
928        _messages: &[ChatMessage],
929        _config: Option<&LLMConfig>,
930    ) -> std::result::Result<
931        Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
932        LLMError,
933    > {
934        self.wait_if_configured().await;
935        if let Some(error) = &self.error {
936            return Err(LLMError::API {
937                message: error.clone(),
938                status: None,
939            });
940        }
941        let response = self.next_response();
942        Ok(Box::new(futures::stream::iter(chunks_from_response(
943            response,
944        ))))
945    }
946
947    fn provider_name(&self) -> &str {
948        "eval-sequence"
949    }
950
951    fn supports(&self, feature: LLMFeature) -> bool {
952        matches!(feature, LLMFeature::Streaming | LLMFeature::SystemMessages)
953    }
954}
955
956fn chunks_from_response(response: LLMResponse) -> Vec<std::result::Result<LLMChunk, LLMError>> {
957    let deltas = split_stream_content(&response.content);
958    if deltas.is_empty() {
959        return vec![Ok(LLMChunk::final_chunk(
960            "",
961            response.finish_reason,
962            response.usage,
963        ))];
964    }
965    let last_index = deltas.len() - 1;
966    deltas
967        .into_iter()
968        .enumerate()
969        .map(|(index, delta)| {
970            if index == last_index {
971                Ok(LLMChunk::final_chunk(
972                    delta,
973                    response.finish_reason.clone(),
974                    response.usage.clone(),
975                ))
976            } else {
977                Ok(LLMChunk::new(delta, false))
978            }
979        })
980        .collect()
981}
982
983fn split_stream_content(content: &str) -> Vec<String> {
984    if content.is_empty() {
985        return Vec::new();
986    }
987    let words: Vec<&str> = content.split_whitespace().collect();
988    if words.len() > 1 {
989        return words
990            .into_iter()
991            .enumerate()
992            .map(|(index, word)| {
993                if index == 0 {
994                    word.to_string()
995                } else {
996                    format!(" {}", word)
997                }
998            })
999            .collect();
1000    }
1001    let chars: Vec<char> = content.chars().collect();
1002    if chars.len() <= 1 {
1003        return vec![content.to_string()];
1004    }
1005    let chunk_size = 4.min(chars.len().div_ceil(2));
1006    chars
1007        .chunks(chunk_size)
1008        .map(|chunk| chunk.iter().collect::<String>())
1009        .collect()
1010}
1011
1012/// LLM provider wrapper appending responses to a cassette file.
1013struct RecordingLLMProvider {
1014    /// Wrapped implementation or shared storage.
1015    inner: Arc<dyn LLMProvider>,
1016    /// LLM alias used for this provider or cassette record.
1017    alias: String,
1018    /// Model or relationship model name.
1019    model: String,
1020    /// Path used for file lookup, HTTP routing, or dot-path checks.
1021    path: PathBuf,
1022}
1023
1024impl RecordingLLMProvider {
1025    fn new(inner: Arc<dyn LLMProvider>, alias: String, model: String, path: PathBuf) -> Self {
1026        Self {
1027            inner,
1028            alias,
1029            model,
1030            path,
1031        }
1032    }
1033}
1034
1035#[async_trait]
1036impl LLMProvider for RecordingLLMProvider {
1037    async fn complete(
1038        &self,
1039        messages: &[ChatMessage],
1040        config: Option<&LLMConfig>,
1041    ) -> std::result::Result<LLMResponse, LLMError> {
1042        let response = self.inner.complete(messages, config).await?;
1043        let record = CassetteRecord {
1044            alias: self.alias.clone(),
1045            model: self.model.clone(),
1046            request_hash: hash_request(messages, config),
1047            request_hash_version: Some("sha256-v1".to_string()),
1048            response: response.clone(),
1049        };
1050        if let Some(parent) = self.path.parent() {
1051            let _ = std::fs::create_dir_all(parent);
1052        }
1053        if let Ok(mut file) = OpenOptions::new()
1054            .create(true)
1055            .append(true)
1056            .open(&self.path)
1057        {
1058            let _ = writeln!(
1059                file,
1060                "{}",
1061                serde_json::to_string(&record).unwrap_or_default()
1062            );
1063        }
1064        Ok(response)
1065    }
1066
1067    async fn complete_stream(
1068        &self,
1069        messages: &[ChatMessage],
1070        config: Option<&LLMConfig>,
1071    ) -> std::result::Result<
1072        Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
1073        LLMError,
1074    > {
1075        self.inner.complete_stream(messages, config).await
1076    }
1077
1078    fn provider_name(&self) -> &str {
1079        self.inner.provider_name()
1080    }
1081
1082    fn supports(&self, feature: LLMFeature) -> bool {
1083        self.inner.supports(feature)
1084    }
1085}
1086
1087fn hash_request(messages: &[ChatMessage], config: Option<&LLMConfig>) -> String {
1088    let canonical_messages: Vec<Value> = messages
1089        .iter()
1090        .map(|message| {
1091            json!({
1092                "role": format!("{:?}", message.role),
1093                "content": message.content,
1094                "name": message.name,
1095            })
1096        })
1097        .collect();
1098    let canonical = json!({
1099        "version": "sha256-v1",
1100        "messages": canonical_messages,
1101        "config": config,
1102    });
1103    let encoded = serde_json::to_vec(&canonical).unwrap_or_default();
1104    let digest = Sha256::digest(encoded);
1105    format!("sha256-v1:{:x}", digest)
1106}
1107
1108fn default_status() -> u16 {
1109    200
1110}
1111
1112fn default_true() -> bool {
1113    true
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    #[test]
1121    fn context_file_and_inline_context_merge() {
1122        let dir = std::env::temp_dir().join(format!(
1123            "ai_agents_eval_fixture_test_{}",
1124            uuid::Uuid::new_v4()
1125        ));
1126        std::fs::create_dir_all(&dir).unwrap();
1127        let context_path = dir.join("context.json");
1128        std::fs::write(
1129            &context_path,
1130            r#"{"user":{"tier":"basic"},"channel":"file"}"#,
1131        )
1132        .unwrap();
1133        let config = FixturesConfig {
1134            context: Some(serde_json::json!({"channel":"inline","feature":true})),
1135            context_file: Some(PathBuf::from("context.json")),
1136            ..Default::default()
1137        };
1138        let context = resolve_fixture_context(&config, &dir).unwrap();
1139        assert_eq!(context.get("channel"), Some(&serde_json::json!("inline")));
1140        assert_eq!(context.get("feature"), Some(&serde_json::json!(true)));
1141        assert!(context.get("user").is_some());
1142        let _ = std::fs::remove_dir_all(dir);
1143    }
1144
1145    #[test]
1146    fn mock_streaming_splits_response_into_multiple_chunks() {
1147        let response = LLMResponse::new(
1148            "Streaming hello from the mocked provider.".to_string(),
1149            FinishReason::Stop,
1150        );
1151        let chunks = chunks_from_response(response);
1152        assert!(chunks.len() > 1);
1153        let mut reconstructed = String::new();
1154        for (index, chunk) in chunks.into_iter().enumerate() {
1155            let chunk = chunk.unwrap();
1156            if index == 0 {
1157                assert!(!chunk.is_final);
1158            }
1159            reconstructed.push_str(&chunk.delta);
1160            if chunk.is_final {
1161                assert!(chunk.finish_reason.is_some());
1162            }
1163        }
1164        assert_eq!(reconstructed, "Streaming hello from the mocked provider.");
1165    }
1166
1167    #[test]
1168    fn mock_streaming_splits_single_word_response() {
1169        let chunks = split_stream_content("Hello");
1170        assert!(chunks.len() > 1);
1171        assert_eq!(chunks.join(""), "Hello");
1172    }
1173
1174    #[tokio::test]
1175    async fn mock_server_serves_configured_route() {
1176        let config = MockServerConfig {
1177            enabled: true,
1178            port: None,
1179            routes: vec![serde_json::json!({
1180                "method":"GET",
1181                "path":"/ok",
1182                "status":200,
1183                "body":{"ok":true}
1184            })],
1185        };
1186        let server = start_mock_server(Some(&config)).await.unwrap().unwrap();
1187        let context = server.context();
1188        let base_url = context
1189            .get("mock_server")
1190            .and_then(|value| value.get("base_url"))
1191            .and_then(Value::as_str)
1192            .unwrap()
1193            .trim_start_matches("http://")
1194            .to_string();
1195        let mut stream = tokio::net::TcpStream::connect(base_url).await.unwrap();
1196        stream
1197            .write_all(b"GET /ok HTTP/1.1\r\nHost: localhost\r\n\r\n")
1198            .await
1199            .unwrap();
1200        let mut response = String::new();
1201        stream.read_to_string(&mut response).await.unwrap();
1202        assert!(response.contains("200 OK"));
1203        assert!(response.contains("{\"ok\":true}"));
1204    }
1205}