Skip to main content

harn_vm/llm/
mock.rs

1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{LazyLock, Mutex, MutexGuard};
6
7use super::api::{LlmResult, ProviderTelemetry};
8use crate::orchestration::ToolCallRecord;
9use crate::value::{ErrorCategory, VmError, VmValue};
10
11/// LLM replay mode.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum LlmReplayMode {
14    Off,
15    Record,
16    Replay,
17}
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20enum CliLlmMockMode {
21    #[default]
22    Off,
23    Replay,
24    Record,
25}
26
27#[derive(Default)]
28struct CliLlmMockState {
29    mode: CliLlmMockMode,
30    mocks: Vec<LlmMock>,
31    recordings: Vec<LlmMock>,
32}
33
34static CLI_LLM_MOCK_NEXT_SCOPE: AtomicU64 = AtomicU64::new(1);
35static CLI_LLM_MOCK_SCOPES: LazyLock<Mutex<BTreeMap<u64, CliLlmMockState>>> =
36    LazyLock::new(|| Mutex::new(BTreeMap::new()));
37
38/// Categorized error injected by a mock. When present, the mock
39/// short-circuits the provider call and surfaces as
40/// `VmError::CategorizedError`, so `llm_call` throws and
41/// `llm_call_safe` populates its `error` envelope.
42#[derive(Clone)]
43pub struct MockError {
44    pub category: ErrorCategory,
45    pub message: String,
46    pub status: Option<u16>,
47    pub kind: Option<String>,
48    pub reason: Option<String>,
49    /// Optional retry hint. Provider-envelope mocks put this directly
50    /// on the thrown dict; legacy category-only mocks embed it in the
51    /// message so the live-provider parser path still exercises the
52    /// same extraction code.
53    pub retry_after_ms: Option<u64>,
54}
55
56impl MockError {
57    fn has_provider_envelope(&self) -> bool {
58        self.status.is_some() || self.kind.is_some() || self.reason.is_some()
59    }
60}
61
62pub(crate) fn build_mock_error(
63    category: Option<String>,
64    message: Option<String>,
65    status: Option<u16>,
66    kind: Option<String>,
67    reason: Option<String>,
68    retry_after_ms: Option<u64>,
69) -> Result<MockError, String> {
70    if retry_after_ms.is_some_and(|ms| ms > i64::MAX as u64) {
71        return Err("error.retry_after_ms must fit in a signed 64-bit integer".to_string());
72    }
73    let kind = match kind {
74        Some(value) if value.trim().is_empty() => None,
75        Some(value) => {
76            let normalized = value.trim().to_ascii_lowercase();
77            if super::api::LlmErrorKind::parse(&normalized).is_none() {
78                return Err(format!("unknown error kind `{value}`"));
79            }
80            Some(normalized)
81        }
82        None => None,
83    };
84    let reason = reason.and_then(|value| {
85        let trimmed = value.trim();
86        if trimmed.is_empty() {
87            None
88        } else {
89            Some(trimmed.to_string())
90        }
91    });
92    let category_was_provided = category.is_some();
93    let category = match category {
94        Some(value) if value.trim().is_empty() => {
95            return Err("error.category must not be empty".to_string());
96        }
97        Some(value) => {
98            let normalized = value.trim().to_ascii_lowercase();
99            let category = ErrorCategory::parse(&normalized);
100            if category.as_str() != normalized {
101                return Err(format!("unknown error category `{value}`"));
102            }
103            category
104        }
105        None => infer_mock_error_category(status, kind.as_deref(), reason.as_deref()),
106    };
107    if !category_was_provided && kind.is_none() && status.is_none() && reason.is_none() {
108        return Err(
109            "error.category is required unless error.status, error.kind, or error.reason is set"
110                .to_string(),
111        );
112    }
113    Ok(MockError {
114        category,
115        message: message.unwrap_or_else(|| {
116            default_mock_error_message(status, kind.as_deref(), reason.as_deref())
117        }),
118        status,
119        kind,
120        reason,
121        retry_after_ms,
122    })
123}
124
125pub(crate) fn validate_mock_error_status(status: i64) -> Result<u16, String> {
126    let status = u16::try_from(status)
127        .map_err(|_| "error.status must be an HTTP status code".to_string())?;
128    reqwest::StatusCode::from_u16(status)
129        .map_err(|_| "error.status must be an HTTP status code".to_string())?;
130    Ok(status)
131}
132
133fn infer_mock_error_category(
134    status: Option<u16>,
135    kind: Option<&str>,
136    reason: Option<&str>,
137) -> ErrorCategory {
138    if let Some(status) = status {
139        match status {
140            401 | 403 => return ErrorCategory::Auth,
141            404 | 410 => return ErrorCategory::NotFound,
142            408 | 504 | 522 | 524 => return ErrorCategory::Timeout,
143            429 => return ErrorCategory::RateLimit,
144            503 | 529 => return ErrorCategory::Overloaded,
145            500 | 502 => return ErrorCategory::ServerError,
146            _ => {}
147        }
148    }
149    if let Some(reason) = reason {
150        match reason {
151            "rate_limit" => return ErrorCategory::RateLimit,
152            "timeout" => return ErrorCategory::Timeout,
153            "network_error" | "transient_network" => return ErrorCategory::TransientNetwork,
154            "server_error" | "provider_error" | "provider_5xx" | "upstream_unavailable" => {
155                return ErrorCategory::ServerError;
156            }
157            "auth_failure" => return ErrorCategory::Auth,
158            "model_unavailable" => return ErrorCategory::NotFound,
159            _ => {}
160        }
161    }
162    if kind == Some("transient") {
163        return ErrorCategory::ServerError;
164    }
165    ErrorCategory::Generic
166}
167
168fn default_mock_error_message(
169    status: Option<u16>,
170    kind: Option<&str>,
171    reason: Option<&str>,
172) -> String {
173    match (status, kind, reason) {
174        (Some(status), Some(kind), Some(reason)) => {
175            format!("HTTP {status} mock LLM error ({kind}/{reason})")
176        }
177        (Some(status), _, Some(reason)) => format!("HTTP {status} mock LLM error ({reason})"),
178        (Some(status), _, _) => format!("HTTP {status} mock LLM error"),
179        (None, Some(kind), Some(reason)) => format!("mock LLM error ({kind}/{reason})"),
180        (None, Some(kind), None) => format!("mock LLM error ({kind})"),
181        (None, None, Some(reason)) => format!("mock LLM error ({reason})"),
182        (None, None, None) => String::new(),
183    }
184}
185
186#[derive(Clone)]
187pub struct LlmMock {
188    pub text: String,
189    pub tool_calls: Vec<serde_json::Value>,
190    pub match_pattern: Option<String>, // None = FIFO (consumed), Some = glob (reusable)
191    pub consume_on_match: bool,
192    pub input_tokens: Option<i64>,
193    pub output_tokens: Option<i64>,
194    pub cache_read_tokens: Option<i64>,
195    pub cache_write_tokens: Option<i64>,
196    pub thinking: Option<String>,
197    pub thinking_summary: Option<String>,
198    pub stop_reason: Option<String>,
199    pub model: String,
200    pub provider: Option<String>,
201    pub blocks: Option<Vec<serde_json::Value>>,
202    pub logprobs: Vec<serde_json::Value>,
203    /// When `Some`, this mock synthesizes an error instead of an
204    /// `LlmResult`. `text`/`tool_calls` are ignored for error mocks.
205    pub error: Option<MockError>,
206    /// Ordered visible-text chunks emitted as separate streaming deltas when
207    /// this mock is consumed through a streaming caller (e.g. `agent_loop`'s
208    /// `on_delta` seam). Empty = the default single-delta behavior, where the
209    /// full `text` is delivered as one delta. When non-empty and `text` is
210    /// empty, `text` is derived from the concatenation of the chunks so the
211    /// non-streaming result and the streamed transcript agree exactly.
212    pub stream_chunks: Vec<String>,
213}
214
215#[derive(Clone)]
216pub(crate) struct LlmMockCall {
217    pub api_mode: String,
218    pub messages: Vec<serde_json::Value>,
219    pub system: Option<String>,
220    pub tools: Option<Vec<serde_json::Value>>,
221    pub provider_tools: Option<Vec<serde_json::Value>>,
222    pub tool_choice: Option<serde_json::Value>,
223    pub output_format: serde_json::Value,
224    pub thinking: serde_json::Value,
225    pub previous_response_id: Option<String>,
226    pub store: Option<bool>,
227    pub background: Option<bool>,
228    pub truncation: Option<String>,
229    pub compact: Option<bool>,
230    pub include: Option<Vec<String>>,
231    pub max_tool_calls: Option<i64>,
232}
233
234type LlmMockScope = (Vec<LlmMock>, Vec<LlmMockCall>, BTreeSet<String>);
235
236thread_local! {
237    static LLM_REPLAY_MODE: RefCell<LlmReplayMode> = const { RefCell::new(LlmReplayMode::Off) };
238    static LLM_FIXTURE_DIR: RefCell<String> = const { RefCell::new(String::new()) };
239    static TOOL_RECORDINGS: RefCell<Vec<ToolCallRecord>> = const { RefCell::new(Vec::new()) };
240    static LLM_MOCKS: RefCell<Vec<LlmMock>> = const { RefCell::new(Vec::new()) };
241    static CLI_LLM_MOCK_SCOPE: RefCell<Option<u64>> = const { RefCell::new(None) };
242    static LLM_MOCK_CALLS: RefCell<Vec<LlmMockCall>> = const { RefCell::new(Vec::new()) };
243    static LLM_PROMPT_CACHE: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
244    static LLM_MOCK_SCOPES: RefCell<Vec<LlmMockScope>> = const { RefCell::new(Vec::new()) };
245    // Scripted streaming chunks for the most recently matched builtin mock,
246    // stashed by `build_mock_result` and drained by the streaming delta pump in
247    // `api.rs`. Per-call and same-thread: set during `mock_llm_response` and
248    // taken immediately after in the same synchronous call on the LocalSet.
249    static LLM_MOCK_STREAM_CHUNKS: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
250}
251
252/// Record the scripted streaming chunks for the mock response currently being
253/// assembled. Overwrites any prior value; cleared at the top of each
254/// `mock_llm_response` so a chunk-less response never inherits stale chunks.
255pub(crate) fn set_mock_stream_chunks(chunks: Option<Vec<String>>) {
256    LLM_MOCK_STREAM_CHUNKS.with(|slot| *slot.borrow_mut() = chunks);
257}
258
259/// Take (and clear) the scripted streaming chunks for the just-produced mock
260/// response. Returns `None` when the response scripted no chunks, in which case
261/// the streaming pump falls back to a single full-text delta.
262pub(crate) fn take_mock_stream_chunks() -> Option<Vec<String>> {
263    LLM_MOCK_STREAM_CHUNKS.with(|slot| slot.borrow_mut().take())
264}
265
266fn cli_llm_mock_scopes() -> MutexGuard<'static, BTreeMap<u64, CliLlmMockState>> {
267    CLI_LLM_MOCK_SCOPES
268        .lock()
269        .unwrap_or_else(|poisoned| poisoned.into_inner())
270}
271
272fn next_cli_llm_mock_scope_id() -> u64 {
273    CLI_LLM_MOCK_NEXT_SCOPE.fetch_add(1, Ordering::Relaxed)
274}
275
276pub(crate) fn current_cli_llm_mock_scope() -> Option<u64> {
277    CLI_LLM_MOCK_SCOPE.with(|scope| *scope.borrow())
278}
279
280fn install_cli_llm_mock_scope(state: CliLlmMockState) {
281    clear_cli_llm_mock_mode();
282    let scope = next_cli_llm_mock_scope_id();
283    cli_llm_mock_scopes().insert(scope, state);
284    CLI_LLM_MOCK_SCOPE.with(|slot| *slot.borrow_mut() = Some(scope));
285}
286
287pub(crate) fn push_llm_mock(mock: LlmMock) {
288    LLM_MOCKS.with(|v| v.borrow_mut().push(mock));
289}
290
291pub(crate) fn get_llm_mock_calls() -> Vec<LlmMockCall> {
292    LLM_MOCK_CALLS.with(|v| v.borrow().clone())
293}
294
295pub(crate) fn builtin_llm_mock_active() -> bool {
296    LLM_MOCKS.with(|v| !v.borrow().is_empty())
297}
298
299pub(crate) fn reset_llm_mock_state() {
300    LLM_MOCKS.with(|v| v.borrow_mut().clear());
301    clear_cli_llm_mock_mode();
302    LLM_MOCK_CALLS.with(|v| v.borrow_mut().clear());
303    LLM_PROMPT_CACHE.with(|v| v.borrow_mut().clear());
304    LLM_MOCK_SCOPES.with(|v| v.borrow_mut().clear());
305}
306
307/// Save the current builtin LLM mock queue and recorded-calls list, then
308/// start a fresh empty scope. Paired with `pop_llm_mock_scope`. Backs
309/// the `with_llm_mocks` helper in `std/testing` so tests reliably
310/// roll back to the prior state, including when the body throws.
311pub(crate) fn push_llm_mock_scope() {
312    let mocks = LLM_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
313    let calls = LLM_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
314    let cache = LLM_PROMPT_CACHE.with(|v| std::mem::take(&mut *v.borrow_mut()));
315    LLM_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls, cache)));
316}
317
318/// Restore the most recently pushed builtin LLM mock scope. Returns
319/// `false` when there is nothing to pop, so the builtin can surface a
320/// clear "imbalanced scope" error rather than silently corrupting
321/// state. CLI-installed mocks are intentionally untouched: they are an
322/// outer harness and should not flicker on each per-test scope swap.
323pub(crate) fn pop_llm_mock_scope() -> bool {
324    let entry = LLM_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
325    match entry {
326        Some((mocks, calls, cache)) => {
327            LLM_MOCKS.with(|v| *v.borrow_mut() = mocks);
328            LLM_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
329            LLM_PROMPT_CACHE.with(|v| *v.borrow_mut() = cache);
330            true
331        }
332        None => false,
333    }
334}
335
336pub fn clear_cli_llm_mock_mode() {
337    let scope = CLI_LLM_MOCK_SCOPE.with(|slot| slot.borrow_mut().take());
338    if let Some(scope) = scope {
339        cli_llm_mock_scopes().remove(&scope);
340    }
341}
342
343pub fn install_cli_llm_mocks(mocks: Vec<LlmMock>) {
344    install_cli_llm_mock_scope(CliLlmMockState {
345        mode: CliLlmMockMode::Replay,
346        mocks,
347        recordings: Vec::new(),
348    });
349}
350
351pub fn enable_cli_llm_mock_recording() {
352    install_cli_llm_mock_scope(CliLlmMockState {
353        mode: CliLlmMockMode::Record,
354        mocks: Vec::new(),
355        recordings: Vec::new(),
356    });
357}
358
359pub fn take_cli_llm_recordings() -> Vec<LlmMock> {
360    let Some(scope) = current_cli_llm_mock_scope() else {
361        return Vec::new();
362    };
363    cli_llm_mock_scopes()
364        .get_mut(&scope)
365        .map(|state| std::mem::take(&mut state.recordings))
366        .unwrap_or_default()
367}
368
369pub(crate) fn cli_llm_mock_replay_active() -> bool {
370    cli_llm_mock_replay_active_for_scope(current_cli_llm_mock_scope())
371}
372
373pub(crate) fn cli_llm_mock_replay_active_for_scope(scope: Option<u64>) -> bool {
374    let Some(scope) = scope else {
375        return false;
376    };
377    cli_llm_mock_scopes()
378        .get(&scope)
379        .is_some_and(|state| state.mode == CliLlmMockMode::Replay)
380}
381
382fn record_llm_mock_call(request: &super::api::LlmRequestPayload) {
383    LLM_MOCK_CALLS.with(|v| {
384        v.borrow_mut().push(LlmMockCall {
385            api_mode: request.api_mode.as_str().to_string(),
386            messages: request.messages.clone(),
387            system: request.system.clone(),
388            tools: request.native_tools.clone(),
389            provider_tools: if request.provider_tools.is_empty() {
390                None
391            } else {
392                Some(request.provider_tools.clone())
393            },
394            tool_choice: request.tool_choice.clone(),
395            output_format: serde_json::to_value(&request.output_format).unwrap_or_else(|_| {
396                serde_json::json!({
397                    "kind": "text"
398                })
399            }),
400            thinking: serde_json::to_value(&request.thinking).unwrap_or_else(|_| {
401                serde_json::json!({
402                    "mode": "disabled"
403                })
404            }),
405            previous_response_id: request.previous_response_id.clone(),
406            store: request.store,
407            background: request.background,
408            truncation: request.truncation.clone(),
409            compact: request.compact,
410            include: request.include.clone(),
411            max_tool_calls: request.max_tool_calls,
412        });
413    });
414}
415
416/// Build an LlmResult from a matched mock.
417fn build_mock_result(mock: &LlmMock, last_msg_len: usize) -> LlmResult {
418    // A mock may script an ordered list of visible-text chunks for streaming
419    // callers. Derive the flat `text` from their concatenation when `text` was
420    // left empty, so the non-streaming result and the streamed transcript agree
421    // byte-for-byte, and stash the chunks for the streaming delta pump.
422    let effective_text = if !mock.stream_chunks.is_empty() && mock.text.is_empty() {
423        mock.stream_chunks.concat()
424    } else {
425        mock.text.clone()
426    };
427    set_mock_stream_chunks(if mock.stream_chunks.is_empty() {
428        None
429    } else {
430        Some(mock.stream_chunks.clone())
431    });
432    let mock = &LlmMock {
433        text: effective_text,
434        ..mock.clone()
435    };
436    let (tool_calls, blocks) = if let Some(blocks) = &mock.blocks {
437        (mock.tool_calls.clone(), blocks.clone())
438    } else {
439        let mut blocks = Vec::new();
440
441        if !mock.text.is_empty() {
442            blocks.push(serde_json::json!({
443                "type": "output_text",
444                "text": mock.text,
445                "visibility": "public",
446            }));
447        }
448
449        let mut tool_calls = Vec::new();
450        for (i, tc) in mock.tool_calls.iter().enumerate() {
451            let id = format!("mock_call_{}", i + 1);
452            let name = tc.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
453            let arguments = tc
454                .get("arguments")
455                .cloned()
456                .unwrap_or(serde_json::json!({}));
457            tool_calls.push(serde_json::json!({
458                "id": id,
459                "type": "tool_call",
460                "name": name,
461                "arguments": arguments,
462            }));
463            blocks.push(serde_json::json!({
464                "type": "tool_call",
465                "id": id,
466                "name": name,
467                "arguments": arguments,
468                "visibility": "internal",
469            }));
470        }
471
472        (tool_calls, blocks)
473    };
474
475    LlmResult {
476        served_fast: false,
477        text: mock.text.clone(),
478        tool_calls,
479        input_tokens: mock.input_tokens.unwrap_or(last_msg_len as i64),
480        output_tokens: mock.output_tokens.unwrap_or(30),
481        cache_read_tokens: mock.cache_read_tokens.unwrap_or(0),
482        cache_write_tokens: mock.cache_write_tokens.unwrap_or(0),
483        cache_supported: true,
484        model: mock.model.clone(),
485        provider: mock.provider.clone().unwrap_or_else(|| "mock".to_string()),
486        thinking: mock.thinking.clone(),
487        thinking_summary: mock.thinking_summary.clone(),
488        stop_reason: mock.stop_reason.clone(),
489        blocks,
490        logprobs: mock.logprobs.clone(),
491        telemetry: ProviderTelemetry::default(),
492    }
493}
494
495// Mock prompt patterns match free prose, where `?`/`[`/`{` are ordinary
496// characters — only `*` is a wildcard. The shared prose matcher keeps that
497// contract (`*`-only ordered segments).
498use harn_glob::match_prose as mock_glob_match;
499
500fn collect_mock_match_strings(value: &serde_json::Value, out: &mut Vec<String>) {
501    match value {
502        serde_json::Value::String(text) if !text.is_empty() => out.push(text.clone()),
503        serde_json::Value::String(_) => {}
504        serde_json::Value::Array(items) => {
505            for item in items {
506                collect_mock_match_strings(item, out);
507            }
508        }
509        serde_json::Value::Object(map) => {
510            for value in map.values() {
511                collect_mock_match_strings(value, out);
512            }
513        }
514        _ => {}
515    }
516}
517
518fn mock_match_text(messages: &[serde_json::Value]) -> String {
519    let mut parts = Vec::new();
520    for message in messages {
521        collect_mock_match_strings(message, &mut parts);
522    }
523    parts.join("\n")
524}
525
526fn mock_last_prompt_text(messages: &[serde_json::Value]) -> String {
527    for message in messages.iter().rev() {
528        let Some(content) = message.get("content") else {
529            continue;
530        };
531        let mut parts = Vec::new();
532        collect_mock_match_strings(content, &mut parts);
533        let text = parts.join("\n");
534        if !text.trim().is_empty() {
535            return text;
536        }
537    }
538    String::new()
539}
540
541fn mock_prompt_cache_key(
542    model: &str,
543    messages: &[serde_json::Value],
544    system: Option<&str>,
545) -> String {
546    serde_json::to_string(&serde_json::json!({
547        "model": model,
548        "system": system,
549        "messages": messages,
550    }))
551    .unwrap_or_default()
552}
553
554fn apply_mock_prompt_cache(result: &mut LlmResult, cache_key: &str) {
555    if result.cache_read_tokens > 0 || result.cache_write_tokens > 0 {
556        return;
557    }
558    let cache_tokens = result.input_tokens.max(0);
559    if cache_tokens == 0 {
560        return;
561    }
562    let cache_hit = LLM_PROMPT_CACHE.with(|cache| {
563        let mut cache = cache.borrow_mut();
564        if cache.contains(cache_key) {
565            true
566        } else {
567            cache.insert(cache_key.to_string());
568            false
569        }
570    });
571    if cache_hit {
572        result.cache_read_tokens = cache_tokens;
573    } else {
574        result.cache_write_tokens = cache_tokens;
575    }
576}
577
578/// Convert a mock's `error` payload into the `VmError` that the
579/// provider path would have raised, so classification, retry, and
580/// `error_category` all behave identically to a real failure.
581fn mock_error_to_vm_error(err: &MockError) -> VmError {
582    let message = mock_error_message(err);
583    if err.has_provider_envelope() {
584        let classified = super::api::classify_llm_error(err.category.clone(), &message);
585        let mut dict = BTreeMap::new();
586        dict.put_str("category", err.category.as_str());
587        dict.put_str(
588            "kind",
589            err.kind
590                .as_deref()
591                .unwrap_or_else(|| classified.kind.as_str()),
592        );
593        dict.put_str(
594            "reason",
595            err.reason
596                .as_deref()
597                .unwrap_or_else(|| classified.reason.as_str()),
598        );
599        dict.put_str("message", message);
600        if let Some(status) = err.status {
601            dict.insert("status".to_string(), VmValue::Int(i64::from(status)));
602        }
603        if let Some(retry_after_ms) = err.retry_after_ms {
604            dict.insert(
605                "retry_after_ms".to_string(),
606                VmValue::Int(retry_after_ms as i64),
607            );
608        }
609        return VmError::Thrown(VmValue::dict(dict));
610    }
611
612    VmError::CategorizedError {
613        message,
614        category: err.category.clone(),
615    }
616}
617
618fn mock_error_message(err: &MockError) -> String {
619    // Embed legacy category-only retry hints into the message so the
620    // same parser that handles live provider headers populates
621    // `retry_after_ms` on the final thrown dict.
622    let Some(ms) = err.retry_after_ms else {
623        return err.message.clone();
624    };
625    if err.has_provider_envelope() {
626        return err.message.clone();
627    }
628    let secs = (ms as f64 / 1000.0).max(0.0);
629    let sep = if err.message.is_empty() || err.message.ends_with('\n') {
630        ""
631    } else {
632        "\n"
633    };
634    format!("{}{sep}retry-after: {secs}\n", err.message)
635}
636
637/// Try to find and return a matching mock response. Returns
638/// `Some(Ok(LlmResult))` on a text/tool_call match, `Some(Err(VmError))`
639/// on an error-mock match, and `None` to fall through to default.
640fn try_match_mock_queue(
641    mocks: &mut Vec<LlmMock>,
642    match_text: &str,
643) -> Option<Result<LlmResult, VmError>> {
644    if let Some(idx) = mocks.iter().position(|m| m.match_pattern.is_none()) {
645        let mock = mocks.remove(idx);
646        return Some(match &mock.error {
647            Some(err) => Err(mock_error_to_vm_error(err)),
648            None => Ok(build_mock_result(&mock, match_text.len())),
649        });
650    }
651
652    for idx in 0..mocks.len() {
653        let mock = &mocks[idx];
654        if let Some(ref pattern) = mock.match_pattern {
655            if mock_glob_match(pattern, match_text) {
656                if mock.consume_on_match {
657                    let mock = mocks.remove(idx);
658                    return Some(match &mock.error {
659                        Some(err) => Err(mock_error_to_vm_error(err)),
660                        None => Ok(build_mock_result(&mock, match_text.len())),
661                    });
662                }
663                return Some(match &mock.error {
664                    Some(err) => Err(mock_error_to_vm_error(err)),
665                    None => Ok(build_mock_result(mock, match_text.len())),
666                });
667            }
668        }
669    }
670
671    None
672}
673
674fn try_match_builtin_mock(match_text: &str) -> Option<Result<LlmResult, VmError>> {
675    LLM_MOCKS.with(|mocks| try_match_mock_queue(&mut mocks.borrow_mut(), match_text))
676}
677
678fn try_match_cli_mock(scope: Option<u64>, match_text: &str) -> Option<Result<LlmResult, VmError>> {
679    let scope = scope?;
680    let mut scopes = cli_llm_mock_scopes();
681    let state = scopes.get_mut(&scope)?;
682    if state.mode != CliLlmMockMode::Replay {
683        return None;
684    }
685    try_match_mock_queue(&mut state.mocks, match_text)
686}
687
688pub(crate) fn record_cli_llm_result(request: &super::api::LlmRequestPayload, result: &LlmResult) {
689    record_unified_tape_llm_call(result);
690    let Some(scope) = request.cli_llm_mock_scope else {
691        return;
692    };
693    let mut scopes = cli_llm_mock_scopes();
694    let Some(state) = scopes.get_mut(&scope) else {
695        return;
696    };
697    if state.mode != CliLlmMockMode::Record {
698        return;
699    }
700    state.recordings.push(LlmMock {
701        text: result.text.clone(),
702        tool_calls: result.tool_calls.clone(),
703        match_pattern: None,
704        consume_on_match: false,
705        input_tokens: Some(result.input_tokens),
706        output_tokens: Some(result.output_tokens),
707        cache_read_tokens: Some(result.cache_read_tokens),
708        cache_write_tokens: Some(result.cache_write_tokens),
709        thinking: result.thinking.clone(),
710        thinking_summary: result.thinking_summary.clone(),
711        stop_reason: result.stop_reason.clone(),
712        model: result.model.clone(),
713        provider: Some(result.provider.clone()),
714        blocks: Some(result.blocks.clone()),
715        logprobs: result.logprobs.clone(),
716        error: None,
717        stream_chunks: Vec::new(),
718    });
719}
720
721/// Append an `LlmCall` record to the unified-tape recorder when one is
722/// active. The request digest is built from the most recently recorded
723/// `LlmMockCall` so the same hashing surface used for fixture matching
724/// drives the fidelity oracle's request comparison; falls back to a
725/// hash of the response text alone when no matching call is on record
726/// (e.g. when `record_llm_mock_call` was bypassed).
727fn record_unified_tape_llm_call(result: &LlmResult) {
728    if crate::testbench::tape::active_recorder().is_none() {
729        return;
730    }
731    let response_json = serde_json::to_vec(result).unwrap_or_else(|_| Vec::new());
732    let request_digest = LLM_MOCK_CALLS
733        .with(|calls| calls.borrow().last().cloned())
734        .map(|call| {
735            let mut request = serde_json::Map::new();
736            request.insert("messages".to_string(), serde_json::json!(call.messages));
737            request.insert("system".to_string(), serde_json::json!(call.system));
738            request.insert("tools".to_string(), serde_json::json!(call.tools));
739            request.insert(
740                "tool_choice".to_string(),
741                serde_json::json!(call.tool_choice),
742            );
743            request.insert("thinking".to_string(), serde_json::json!(call.thinking));
744            request.insert("model".to_string(), serde_json::json!(result.model));
745            if call.api_mode != "chat_completions" {
746                request.insert("api_mode".to_string(), serde_json::json!(call.api_mode));
747            }
748            if call.provider_tools.is_some() {
749                request.insert(
750                    "provider_tools".to_string(),
751                    serde_json::json!(call.provider_tools),
752                );
753            }
754            if call
755                .output_format
756                .get("kind")
757                .and_then(|value| value.as_str())
758                != Some("text")
759            {
760                request.insert(
761                    "output_format".to_string(),
762                    serde_json::json!(call.output_format),
763                );
764            }
765            if call.previous_response_id.is_some() {
766                request.insert(
767                    "previous_response_id".to_string(),
768                    serde_json::json!(call.previous_response_id),
769                );
770            }
771            if call.store.is_some() {
772                request.insert("store".to_string(), serde_json::json!(call.store));
773            }
774            if call.background.is_some() {
775                request.insert("background".to_string(), serde_json::json!(call.background));
776            }
777            if call.truncation.is_some() {
778                request.insert("truncation".to_string(), serde_json::json!(call.truncation));
779            }
780            if call.compact.is_some() {
781                request.insert("compact".to_string(), serde_json::json!(call.compact));
782            }
783            if call.include.is_some() {
784                request.insert("include".to_string(), serde_json::json!(call.include));
785            }
786            if call.max_tool_calls.is_some() {
787                request.insert(
788                    "max_tool_calls".to_string(),
789                    serde_json::json!(call.max_tool_calls),
790                );
791            }
792            let serialized =
793                serde_json::to_vec(&serde_json::Value::Object(request)).unwrap_or_default();
794            crate::testbench::tape::content_hash(&serialized)
795        })
796        .unwrap_or_else(|| {
797            // Fall back to hashing the response — keeps fidelity comparable
798            // across runs even when the request surface wasn't captured.
799            crate::testbench::tape::content_hash(result.text.as_bytes())
800        });
801    crate::testbench::tape::with_active_recorder(|recorder| {
802        let response = recorder.payload_from_bytes(response_json);
803        Some(crate::testbench::tape::TapeRecordKind::LlmCall {
804            request_digest,
805            response,
806        })
807    });
808}
809
810fn unmatched_cli_prompt_error(match_text: &str) -> VmError {
811    let mut snippet: String = match_text.chars().take(200).collect();
812    if match_text.chars().count() > 200 {
813        snippet.push_str("...");
814    }
815    VmError::Runtime(format!("No --llm-mock fixture matched prompt: {snippet:?}"))
816}
817
818/// Set LLM replay mode (record/replay) and fixture directory.
819pub fn set_replay_mode(mode: LlmReplayMode, fixture_dir: &str) {
820    LLM_REPLAY_MODE.with(|v| *v.borrow_mut() = mode);
821    LLM_FIXTURE_DIR.with(|v| *v.borrow_mut() = fixture_dir.to_string());
822}
823
824pub(crate) fn get_replay_mode() -> LlmReplayMode {
825    LLM_REPLAY_MODE.with(|v| *v.borrow())
826}
827
828pub(crate) fn get_fixture_dir() -> String {
829    LLM_FIXTURE_DIR.with(|v| v.borrow().clone())
830}
831
832/// Hash a request for fixture file naming using canonical JSON serialization.
833pub(crate) fn fixture_hash(
834    model: &str,
835    messages: &[serde_json::Value],
836    system: Option<&str>,
837) -> String {
838    use std::hash::{Hash, Hasher};
839    let mut hasher = std::collections::hash_map::DefaultHasher::new();
840    model.hash(&mut hasher);
841    // Canonical JSON hashing is stable across Debug-format changes.
842    serde_json::to_string(messages)
843        .unwrap_or_default()
844        .hash(&mut hasher);
845    system.hash(&mut hasher);
846    format!("{:016x}", hasher.finish())
847}
848
849pub(crate) fn save_fixture(hash: &str, result: &LlmResult) {
850    let dir = get_fixture_dir();
851    if dir.is_empty() {
852        return;
853    }
854    let _ = std::fs::create_dir_all(&dir);
855    let path = format!("{dir}/{hash}.json");
856    let json = serde_json::json!({
857        "text": result.text,
858        "tool_calls": result.tool_calls,
859        "input_tokens": result.input_tokens,
860        "output_tokens": result.output_tokens,
861        "cache_read_tokens": result.cache_read_tokens,
862        "cache_write_tokens": result.cache_write_tokens,
863        "cache_creation_input_tokens": result.cache_write_tokens,
864        "model": result.model,
865        "provider": result.provider,
866        "thinking": result.thinking,
867        "thinking_summary": result.thinking_summary,
868        "stop_reason": result.stop_reason,
869        "blocks": result.blocks,
870        "logprobs": result.logprobs,
871    });
872    let _ = std::fs::write(
873        &path,
874        serde_json::to_string_pretty(&json).unwrap_or_default(),
875    );
876}
877
878pub(crate) fn load_fixture(hash: &str) -> Option<LlmResult> {
879    let dir = get_fixture_dir();
880    if dir.is_empty() {
881        return None;
882    }
883    let path = format!("{dir}/{hash}.json");
884    let content = std::fs::read_to_string(&path).ok()?;
885    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
886    Some(LlmResult {
887        served_fast: false,
888        text: json["text"].as_str().unwrap_or("").to_string(),
889        tool_calls: json["tool_calls"].as_array().cloned().unwrap_or_default(),
890        input_tokens: json["input_tokens"].as_i64().unwrap_or(0),
891        output_tokens: json["output_tokens"].as_i64().unwrap_or(0),
892        cache_read_tokens: json["cache_read_tokens"].as_i64().unwrap_or(0),
893        cache_write_tokens: json["cache_write_tokens"]
894            .as_i64()
895            .or_else(|| json["cache_creation_input_tokens"].as_i64())
896            .unwrap_or(0),
897        cache_supported: json["cache_supported"].as_bool().unwrap_or(true),
898        model: json["model"].as_str().unwrap_or("").to_string(),
899        provider: json["provider"].as_str().unwrap_or("mock").to_string(),
900        thinking: json["thinking"].as_str().map(|s| s.to_string()),
901        thinking_summary: json["thinking_summary"].as_str().map(|s| s.to_string()),
902        stop_reason: json["stop_reason"].as_str().map(|s| s.to_string()),
903        blocks: json["blocks"].as_array().cloned().unwrap_or_default(),
904        logprobs: json["logprobs"].as_array().cloned().unwrap_or_default(),
905        telemetry: serde_json::from_value(json["telemetry"].clone()).unwrap_or_default(),
906    })
907}
908
909/// Generate stub argument values for required parameters in a tool schema.
910/// This makes mock tool calls realistic — a real model would always fill
911/// required fields, so the mock should too.
912fn mock_required_args(tool_schema: &serde_json::Value) -> serde_json::Value {
913    let mut args = serde_json::Map::new();
914    // Anthropic: {name, input_schema: {properties, required}}
915    // OpenAI:    {function: {name, parameters: {properties, required}}}
916    // Harn VM:   {parameters: {name: {type, required}}}  (from tool_define)
917    let input_schema = tool_schema
918        .get("input_schema")
919        .or_else(|| tool_schema.get("inputSchema"))
920        .or_else(|| {
921            tool_schema
922                .get("function")
923                .and_then(|f| f.get("parameters"))
924        })
925        .or_else(|| tool_schema.get("parameters"));
926    let Some(schema) = input_schema else {
927        return serde_json::Value::Object(args);
928    };
929    let required: std::collections::BTreeSet<String> = schema
930        .get("required")
931        .and_then(|r| r.as_array())
932        .map(|arr| {
933            arr.iter()
934                .filter_map(|v| v.as_str().map(|s| s.to_string()))
935                .collect()
936        })
937        .unwrap_or_default();
938    if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
939        for (name, prop) in props {
940            if !required.contains(name) {
941                continue;
942            }
943            let ty = prop
944                .get("type")
945                .and_then(|t| t.as_str())
946                .unwrap_or("string");
947            let placeholder = match ty {
948                "integer" => serde_json::json!(0),
949                "number" => serde_json::json!(0.0),
950                "boolean" => serde_json::json!(false),
951                "array" => serde_json::json!([]),
952                "object" => serde_json::json!({}),
953                _ => serde_json::json!(""),
954            };
955            args.insert(name.clone(), placeholder);
956        }
957    }
958    serde_json::Value::Object(args)
959}
960
961fn mock_tool_name(tool: &serde_json::Value) -> Option<&str> {
962    tool.get("name")
963        .or_else(|| {
964            tool.get("function")
965                .and_then(|function| function.get("name"))
966        })
967        .and_then(|name| name.as_str())
968}
969
970fn mock_auto_tool_candidate(tools: &[serde_json::Value]) -> Option<&serde_json::Value> {
971    tools
972        .iter()
973        .find(|tool| mock_tool_name(tool) != Some("agent_await_resumption"))
974}
975
976/// Mock LLM provider -- deterministic responses for testing without API keys.
977/// When configurable mocks have been registered via `llm_mock()`, those are
978/// checked first (FIFO queue, then pattern matching). Falls through to the
979/// default deterministic behavior when no mocks match.
980pub(crate) fn mock_llm_response(
981    request: &super::api::LlmRequestPayload,
982) -> Result<LlmResult, VmError> {
983    record_llm_mock_call(request);
984    // Reset per-call so a response that scripts no chunks (auto tool calls,
985    // generated prose, error mocks) never inherits a prior mock's chunks.
986    set_mock_stream_chunks(None);
987
988    let messages = &request.messages;
989    let system = request.system.as_deref();
990    let match_text = mock_match_text(messages);
991    let prompt_text = mock_last_prompt_text(messages);
992    let cache_key = mock_prompt_cache_key(&request.model, messages, system);
993
994    if let Some(matched) = try_match_cli_mock(request.cli_llm_mock_scope, &match_text) {
995        return matched.map(|mut result| {
996            if request.cache {
997                apply_mock_prompt_cache(&mut result, &cache_key);
998            }
999            result
1000        });
1001    }
1002
1003    if let Some(matched) = try_match_builtin_mock(&match_text) {
1004        return matched.map(|mut result| {
1005            if request.cache {
1006                apply_mock_prompt_cache(&mut result, &cache_key);
1007            }
1008            result
1009        });
1010    }
1011
1012    if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) {
1013        return Err(unmatched_cli_prompt_error(&match_text));
1014    }
1015
1016    // Generate a mock tool call for the first tool, filling required
1017    // params with placeholders so the call passes schema validation.
1018    if let Some(tools) = request.native_tools.as_deref() {
1019        if let Some(first_tool) = mock_auto_tool_candidate(tools) {
1020            let tool_name = mock_tool_name(first_tool).unwrap_or("unknown");
1021            let mock_args = mock_required_args(first_tool);
1022            let mut result = LlmResult {
1023                served_fast: false,
1024                text: String::new(),
1025                tool_calls: vec![serde_json::json!({
1026                        "id": "mock_call_1",
1027                        "type": "tool_call",
1028                        "name": tool_name,
1029                "arguments": mock_args
1030                })],
1031                input_tokens: prompt_text.len() as i64,
1032                output_tokens: 20,
1033                cache_read_tokens: 0,
1034                cache_write_tokens: 0,
1035                cache_supported: true,
1036                model: request.model.clone(),
1037                provider: "mock".to_string(),
1038                thinking: None,
1039                thinking_summary: None,
1040                stop_reason: None,
1041                blocks: vec![serde_json::json!({
1042                    "type": "tool_call",
1043                    "id": "mock_call_1",
1044                    "name": tool_name,
1045                    "arguments": mock_args,
1046                    "visibility": "internal",
1047                })],
1048                logprobs: Vec::new(),
1049                telemetry: ProviderTelemetry::default(),
1050            };
1051            if request.cache {
1052                apply_mock_prompt_cache(&mut result, &cache_key);
1053            }
1054            return Ok(result);
1055        }
1056    }
1057
1058    // Preserve the historical auto-complete behavior for tagged text-tool
1059    // prompts only. Bare `##DONE##` in no-tool/native prompts changes
1060    // loop semantics by completing runs that used to exhaust budget unless
1061    // a fixture explicitly returned the sentinel.
1062    let tagged_done = system.is_some_and(|s| s.contains("<done>"));
1063
1064    let prose_body = if prompt_text.is_empty() {
1065        "Mock LLM response".to_string()
1066    } else {
1067        let word_count = prompt_text.split_whitespace().count();
1068        format!(
1069            "Mock response to {word_count}-word prompt: {}",
1070            prompt_text.chars().take(100).collect::<String>()
1071        )
1072    };
1073    let response = if tagged_done {
1074        format!("<assistant_prose>{prose_body}</assistant_prose>\n<done>##DONE##</done>")
1075    } else {
1076        prose_body
1077    };
1078
1079    let mut result = LlmResult {
1080        served_fast: false,
1081        text: response.clone(),
1082        tool_calls: vec![],
1083        input_tokens: prompt_text.len() as i64,
1084        output_tokens: 30,
1085        cache_read_tokens: 0,
1086        cache_write_tokens: 0,
1087        cache_supported: true,
1088        model: request.model.clone(),
1089        provider: "mock".to_string(),
1090        thinking: None,
1091        thinking_summary: None,
1092        stop_reason: None,
1093        blocks: vec![serde_json::json!({
1094            "type": "output_text",
1095            "text": response,
1096            "visibility": "public",
1097        })],
1098        logprobs: Vec::new(),
1099        telemetry: ProviderTelemetry::default(),
1100    };
1101    if request.cache {
1102        apply_mock_prompt_cache(&mut result, &cache_key);
1103    }
1104    Ok(result)
1105}
1106
1107/// Take all recorded tool calls, leaving the buffer empty.
1108pub fn drain_tool_recordings() -> Vec<ToolCallRecord> {
1109    TOOL_RECORDINGS.with(|v| std::mem::take(&mut *v.borrow_mut()))
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::llm::api::LlmRequestPayload;
1116
1117    fn text_mock(text: &str) -> LlmMock {
1118        LlmMock {
1119            text: text.to_string(),
1120            tool_calls: Vec::new(),
1121            match_pattern: None,
1122            consume_on_match: false,
1123            input_tokens: None,
1124            output_tokens: None,
1125            cache_read_tokens: None,
1126            cache_write_tokens: None,
1127            thinking: None,
1128            thinking_summary: None,
1129            stop_reason: None,
1130            model: "fixture-model".to_string(),
1131            provider: None,
1132            blocks: None,
1133            logprobs: Vec::new(),
1134            error: None,
1135            stream_chunks: Vec::new(),
1136        }
1137    }
1138
1139    #[test]
1140    fn cli_llm_mock_replay_scope_survives_provider_worker_thread() {
1141        reset_llm_mock_state();
1142        install_cli_llm_mocks(vec![text_mock("cross-thread replay")]);
1143        let request = LlmRequestPayload::from(&crate::llm::api::options::base_opts("anthropic"));
1144
1145        assert!(request.cli_llm_mock_scope.is_some());
1146        assert!(crate::llm::providers::MockProvider::should_intercept_request(&request));
1147
1148        let result = std::thread::spawn(move || {
1149            assert!(crate::llm::providers::MockProvider::should_intercept_request(&request));
1150            mock_llm_response(&request)
1151        })
1152        .join()
1153        .expect("provider worker thread")
1154        .expect("mock response");
1155
1156        assert_eq!(result.text, "cross-thread replay");
1157        clear_cli_llm_mock_mode();
1158    }
1159
1160    #[test]
1161    fn cli_llm_mock_record_scope_collects_provider_worker_thread_results() {
1162        reset_llm_mock_state();
1163        enable_cli_llm_mock_recording();
1164        let request = LlmRequestPayload::from(&crate::llm::api::options::base_opts("anthropic"));
1165        let result = build_mock_result(&text_mock("cross-thread record"), 7);
1166
1167        assert!(request.cli_llm_mock_scope.is_some());
1168        std::thread::spawn(move || record_cli_llm_result(&request, &result))
1169            .join()
1170            .expect("provider worker thread");
1171
1172        let recordings = take_cli_llm_recordings();
1173        assert_eq!(recordings.len(), 1);
1174        assert_eq!(recordings[0].text, "cross-thread record");
1175        clear_cli_llm_mock_mode();
1176    }
1177}