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