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::{Arc, LazyLock, Mutex, MutexGuard};
6
7use super::api::{LlmResult, ProviderTelemetry, RawProviderToolCall};
8use super::mock_store::{MockQueue, QueueMatch};
9use crate::orchestration::ToolCallRecord;
10use crate::value::{ErrorCategory, VmError, VmValue};
11
12/// LLM replay mode.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LlmReplayMode {
15    Off,
16    Record,
17    Replay,
18}
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21enum CliLlmMockMode {
22    #[default]
23    Off,
24    Replay,
25    Record,
26}
27
28#[derive(Default)]
29struct CliLlmMockState {
30    mode: CliLlmMockMode,
31    queue: MockQueue,
32    recordings: Vec<LlmMock>,
33}
34
35static CLI_LLM_MOCK_NEXT_SCOPE: AtomicU64 = AtomicU64::new(1);
36static CLI_LLM_MOCK_SCOPES: LazyLock<Mutex<BTreeMap<u64, CliLlmMockState>>> =
37    LazyLock::new(|| Mutex::new(BTreeMap::new()));
38
39struct CliLlmMockLease(u64);
40
41impl Drop for CliLlmMockLease {
42    fn drop(&mut self) {
43        cli_llm_mock_scopes().remove(&self.0);
44    }
45}
46
47/// Categorized error injected by a mock. When present, the mock
48/// short-circuits the provider call and surfaces as
49/// `VmError::CategorizedError`, so `llm_call` throws and
50/// `llm_call_safe` populates its `error` envelope.
51#[derive(Clone, Debug)]
52pub struct MockError {
53    pub category: ErrorCategory,
54    pub message: String,
55    pub status: Option<u16>,
56    pub kind: Option<String>,
57    pub reason: Option<String>,
58    /// Optional retry hint. Provider-envelope mocks put this directly
59    /// on the thrown dict; legacy category-only mocks embed it in the
60    /// message so the live-provider parser path still exercises the
61    /// same extraction code.
62    pub retry_after_ms: Option<u64>,
63}
64
65impl MockError {
66    fn has_provider_envelope(&self) -> bool {
67        self.status.is_some() || self.kind.is_some() || self.reason.is_some()
68    }
69}
70
71pub(crate) fn build_mock_error(
72    category: Option<String>,
73    message: Option<String>,
74    status: Option<u16>,
75    kind: Option<String>,
76    reason: Option<String>,
77    retry_after_ms: Option<u64>,
78) -> Result<MockError, String> {
79    if retry_after_ms.is_some_and(|ms| ms > i64::MAX as u64) {
80        return Err("error.retry_after_ms must fit in a signed 64-bit integer".to_string());
81    }
82    let kind = match kind {
83        Some(value) if value.trim().is_empty() => None,
84        Some(value) => {
85            let normalized = value.trim().to_ascii_lowercase();
86            if super::api::LlmErrorKind::parse(&normalized).is_none() {
87                return Err(format!("unknown error kind `{value}`"));
88            }
89            Some(normalized)
90        }
91        None => None,
92    };
93    let reason = reason.and_then(|value| {
94        let trimmed = value.trim();
95        if trimmed.is_empty() {
96            None
97        } else {
98            Some(trimmed.to_string())
99        }
100    });
101    let category_was_provided = category.is_some();
102    let category = match category {
103        Some(value) if value.trim().is_empty() => {
104            return Err("error.category must not be empty".to_string());
105        }
106        Some(value) => {
107            let normalized = value.trim().to_ascii_lowercase();
108            let category = ErrorCategory::parse(&normalized);
109            if category.as_str() != normalized {
110                return Err(format!("unknown error category `{value}`"));
111            }
112            category
113        }
114        None => infer_mock_error_category(status, kind.as_deref(), reason.as_deref()),
115    };
116    if !category_was_provided && kind.is_none() && status.is_none() && reason.is_none() {
117        return Err(
118            "error.category is required unless error.status, error.kind, or error.reason is set"
119                .to_string(),
120        );
121    }
122    Ok(MockError {
123        category,
124        message: message.unwrap_or_else(|| {
125            default_mock_error_message(status, kind.as_deref(), reason.as_deref())
126        }),
127        status,
128        kind,
129        reason,
130        retry_after_ms,
131    })
132}
133
134pub(crate) fn validate_mock_error_status(status: i64) -> Result<u16, String> {
135    let status = u16::try_from(status)
136        .map_err(|_| "error.status must be an HTTP status code".to_string())?;
137    reqwest::StatusCode::from_u16(status)
138        .map_err(|_| "error.status must be an HTTP status code".to_string())?;
139    Ok(status)
140}
141
142fn infer_mock_error_category(
143    status: Option<u16>,
144    kind: Option<&str>,
145    reason: Option<&str>,
146) -> ErrorCategory {
147    if let Some(status) = status {
148        match status {
149            401 | 403 => return ErrorCategory::Auth,
150            404 | 410 => return ErrorCategory::NotFound,
151            408 | 504 | 522 | 524 => return ErrorCategory::Timeout,
152            429 => return ErrorCategory::RateLimit,
153            503 | 529 => return ErrorCategory::Overloaded,
154            500 | 502 => return ErrorCategory::ServerError,
155            _ => {}
156        }
157    }
158    if let Some(reason) = reason {
159        match reason {
160            "rate_limit" => return ErrorCategory::RateLimit,
161            "timeout" => return ErrorCategory::Timeout,
162            "network_error" | "transient_network" => return ErrorCategory::TransientNetwork,
163            "server_error" | "provider_error" | "provider_5xx" | "upstream_unavailable" => {
164                return ErrorCategory::ServerError;
165            }
166            "auth_failure" => return ErrorCategory::Auth,
167            "model_unavailable" => return ErrorCategory::NotFound,
168            _ => {}
169        }
170    }
171    if kind == Some("transient") {
172        return ErrorCategory::ServerError;
173    }
174    ErrorCategory::Generic
175}
176
177fn default_mock_error_message(
178    status: Option<u16>,
179    kind: Option<&str>,
180    reason: Option<&str>,
181) -> String {
182    match (status, kind, reason) {
183        (Some(status), Some(kind), Some(reason)) => {
184            format!("HTTP {status} mock LLM error ({kind}/{reason})")
185        }
186        (Some(status), _, Some(reason)) => format!("HTTP {status} mock LLM error ({reason})"),
187        (Some(status), _, _) => format!("HTTP {status} mock LLM error"),
188        (None, Some(kind), Some(reason)) => format!("mock LLM error ({kind}/{reason})"),
189        (None, Some(kind), None) => format!("mock LLM error ({kind})"),
190        (None, None, Some(reason)) => format!("mock LLM error ({reason})"),
191        (None, None, None) => String::new(),
192    }
193}
194
195/// The scope a fixture entry belongs to when no `scope` is authored, and the
196/// scope a call requests when it sets no `mock_scope`. V0 fixtures use this as
197/// their compatibility bucket; non-strict V1 fixtures retain its legacy
198/// fallback behavior.
199pub const DEFAULT_MOCK_SCOPE: &str = "default";
200
201/// An explicitly authored absorber for auxiliary calls that miss their own
202/// scope. Unlike [`DEFAULT_MOCK_SCOPE`], this fallback remains active when
203/// `strictScopes` is true, so a fixture can combine loud scope misses with one
204/// deliberate shared response.
205pub const SHARED_MOCK_SCOPE: &str = "shared";
206
207/// Advisory vocabulary for the purposes Harn itself assigns to LLM calls.
208/// Fixture scopes remain open strings so applications can add their own
209/// purposes without changing the runtime, but spelling a Harn purpose in a
210/// fixture gets linted against this producer-owned list.
211pub const KNOWN_MOCK_SCOPES: &[&str] = &[
212    DEFAULT_MOCK_SCOPE,
213    SHARED_MOCK_SCOPE,
214    "agent.main",
215    "agent.input_guardrail",
216    "agent.scope_classifier",
217    "compaction",
218    "completion.judge",
219    "step.judge",
220];
221
222/// A typed consumption receipt emitted once per mock-provider dispatch when a
223/// fixture set is active. It records both sides of fallback so a caller can
224/// prove that a response came from the requested purpose, an explicit shared
225/// absorber, or the legacy default bucket without inspecting queue internals.
226#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct MockConsumptionReceipt {
228    pub requested_scope: String,
229    pub resolved_scope: String,
230    pub matched: bool,
231    pub id: String,
232    /// `"once"` | `"sticky"` on a hit; empty on a miss.
233    pub consume: String,
234    pub fell_through: bool,
235    pub remaining: usize,
236}
237
238impl MockConsumptionReceipt {
239    pub(crate) fn hit(
240        requested_scope: &str,
241        resolved_scope: &str,
242        mock: &LlmMock,
243        fell_through: bool,
244        remaining: usize,
245    ) -> Self {
246        Self {
247            requested_scope: requested_scope.to_string(),
248            resolved_scope: resolved_scope.to_string(),
249            matched: true,
250            id: mock.entry_id.clone(),
251            consume: consume_label(mock.sticky).to_string(),
252            fell_through,
253            remaining,
254        }
255    }
256
257    pub(crate) fn miss(requested_scope: &str, remaining: usize) -> Self {
258        Self {
259            requested_scope: requested_scope.to_string(),
260            resolved_scope: String::new(),
261            matched: false,
262            id: String::new(),
263            consume: String::new(),
264            fell_through: false,
265            remaining,
266        }
267    }
268}
269
270/// Consumption policy label for a matched entry.
271fn consume_label(sticky: bool) -> &'static str {
272    if sticky {
273        "sticky"
274    } else {
275        "once"
276    }
277}
278
279#[derive(Clone, Debug)]
280pub struct LlmMock {
281    pub text: String,
282    pub tool_calls: Vec<serde_json::Value>,
283    pub raw_tool_calls: Vec<RawProviderToolCall>,
284    pub match_pattern: Option<String>, // None = FIFO, Some = glob
285    /// Scope bucket this entry serves. Scope strings are open and unknown
286    /// values remain valid isolated buckets; the parser only advises on
287    /// scopes outside [`KNOWN_MOCK_SCOPES`]. Defaults to [`DEFAULT_MOCK_SCOPE`].
288    pub scope: String,
289    /// Stable per-entry identifier, surfaced on the consumption receipt.
290    /// Authored `id` when present, else the load-time entry index. Assigned
291    /// once at load and never shifts as sibling entries are consumed.
292    pub entry_id: String,
293    /// Reusable when `true`: the entry matches repeatedly and is never
294    /// consumed (a `0..N`-times classifier). When `false` it is a one-shot
295    /// queue slot, removed after it matches.
296    pub sticky: bool,
297    pub input_tokens: Option<i64>,
298    pub output_tokens: Option<i64>,
299    pub cache_read_tokens: Option<i64>,
300    pub cache_write_tokens: Option<i64>,
301    pub thinking: Option<String>,
302    pub thinking_summary: Option<String>,
303    pub stop_reason: Option<String>,
304    pub model: String,
305    pub provider: Option<String>,
306    pub blocks: Option<Vec<serde_json::Value>>,
307    pub logprobs: Vec<serde_json::Value>,
308    /// When `Some`, this mock synthesizes an error instead of an
309    /// `LlmResult`. `text`/`tool_calls` are ignored for error mocks.
310    pub error: Option<MockError>,
311    /// Ordered visible-text chunks emitted as separate streaming deltas when
312    /// this mock is consumed through a streaming caller (e.g. `agent_loop`'s
313    /// `on_delta` seam). Empty = the default single-delta behavior, where the
314    /// full `text` is delivered as one delta. When non-empty and `text` is
315    /// empty, `text` is derived from the concatenation of the chunks so the
316    /// non-streaming result and the streamed transcript agree exactly.
317    pub stream_chunks: Vec<String>,
318}
319
320/// A parsed JSONL fixture: the contract version pinned by the optional header,
321/// the file-level `strictScopes` opt-in, and the entries. A file with no
322/// header is contract v0 (`schema_version == 0`), replayed byte-identically to
323/// the pre-contract single-scope, first-match-wins queue.
324#[derive(Clone, Debug, Default)]
325pub struct LlmMockFixture {
326    pub schema_version: u32,
327    pub strict_scopes: bool,
328    pub mocks: Vec<LlmMock>,
329    pub warnings: Vec<String>,
330}
331
332/// Producer-owned facts returned after atomically installing a fixture
333/// document. Consumers use this instead of inspecting mutable queue state.
334#[derive(Clone, Debug, PartialEq, Eq)]
335pub(crate) struct LlmMockFixtureReceipt {
336    pub schema_version: u32,
337    pub strict_scopes: bool,
338    pub count: usize,
339    pub scopes: Vec<String>,
340    pub warnings: Vec<String>,
341}
342
343/// The highest fixture contract version this build understands.
344pub const MAX_MOCK_SCHEMA_VERSION: u32 = 1;
345
346#[derive(Clone)]
347pub(crate) struct LlmMockCall {
348    pub mock_scope: String,
349    pub api_mode: String,
350    pub messages: Vec<serde_json::Value>,
351    pub system: Option<String>,
352    pub tools: Option<Vec<serde_json::Value>>,
353    pub provider_tools: Option<Vec<serde_json::Value>>,
354    pub tool_choice: Option<serde_json::Value>,
355    pub output_format: serde_json::Value,
356    pub thinking: serde_json::Value,
357    pub max_tokens: i64,
358    pub temperature: Option<f64>,
359    pub top_p: Option<f64>,
360    pub top_k: Option<i64>,
361    pub stop: Option<Vec<String>>,
362    pub seed: Option<i64>,
363    pub frequency_penalty: Option<f64>,
364    pub presence_penalty: Option<f64>,
365    pub previous_response_id: Option<String>,
366    pub store: Option<bool>,
367    pub background: Option<bool>,
368    pub truncation: Option<String>,
369    pub compact: Option<bool>,
370    pub include: Option<Vec<String>>,
371    pub max_tool_calls: Option<i64>,
372    pub prefill: Option<String>,
373}
374
375type LlmMockScope = (
376    MockQueue,
377    Vec<LlmMockCall>,
378    BTreeSet<String>,
379    Vec<MockConsumptionReceipt>,
380);
381
382#[derive(Default)]
383struct LlmMockState {
384    builtin_queue: MockQueue,
385    calls: Vec<LlmMockCall>,
386    prompt_cache: BTreeSet<String>,
387    scopes: Vec<LlmMockScope>,
388    receipts: Vec<MockConsumptionReceipt>,
389    cli_scope: Option<Arc<CliLlmMockLease>>,
390}
391
392/// Shared mutable mock state for one VM execution tree.
393///
394/// Child VMs and inline async tasks clone this handle, while independently
395/// constructed VMs receive distinct handles. `AmbientExecutionScope` swaps the
396/// handle on every future poll, so builtin calls always resolve through the
397/// logical VM even when the executor moves that future to another thread.
398#[derive(Clone, Default)]
399pub(crate) struct LlmMockContext(Arc<Mutex<LlmMockState>>);
400
401impl LlmMockContext {
402    pub(crate) fn for_new_vm() -> Self {
403        let context = Self::default();
404        context.lock().cli_scope = current_cli_llm_mock_lease();
405        context
406    }
407
408    fn lock(&self) -> MutexGuard<'_, LlmMockState> {
409        self.0
410            .lock()
411            .unwrap_or_else(|poisoned| poisoned.into_inner())
412    }
413}
414
415thread_local! {
416    static LLM_REPLAY_MODE: RefCell<LlmReplayMode> = const { RefCell::new(LlmReplayMode::Off) };
417    static LLM_FIXTURE_DIR: RefCell<String> = const { RefCell::new(String::new()) };
418    static TOOL_RECORDINGS: RefCell<Vec<ToolCallRecord>> = const { RefCell::new(Vec::new()) };
419    static LLM_MOCK_CONTEXT: RefCell<LlmMockContext> = RefCell::new(LlmMockContext::default());
420    // Scripted streaming chunks for the most recently matched builtin mock,
421    // stashed by `build_mock_result` and drained by the streaming delta pump in
422    // `api.rs`. Per-call and same-thread: set during `mock_llm_response` and
423    // taken immediately after in the same synchronous call on the LocalSet.
424    static LLM_MOCK_STREAM_CHUNKS: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
425}
426
427pub(crate) fn swap_llm_mock_context(next: LlmMockContext) -> LlmMockContext {
428    LLM_MOCK_CONTEXT.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), next))
429}
430
431pub(crate) fn current_llm_mock_context() -> LlmMockContext {
432    LLM_MOCK_CONTEXT.with(|slot| slot.borrow().clone())
433}
434
435fn with_mock_state<T>(f: impl FnOnce(&LlmMockState) -> T) -> T {
436    let context = current_llm_mock_context();
437    let state = context.lock();
438    f(&state)
439}
440
441fn with_mock_state_mut<T>(f: impl FnOnce(&mut LlmMockState) -> T) -> T {
442    let context = current_llm_mock_context();
443    let mut state = context.lock();
444    f(&mut state)
445}
446
447/// Record the scripted streaming chunks for the mock response currently being
448/// assembled. Overwrites any prior value; cleared at the top of each
449/// `mock_llm_response` so a chunk-less response never inherits stale chunks.
450pub(crate) fn set_mock_stream_chunks(chunks: Option<Vec<String>>) {
451    LLM_MOCK_STREAM_CHUNKS.with(|slot| *slot.borrow_mut() = chunks);
452}
453
454/// Take (and clear) the scripted streaming chunks for the just-produced mock
455/// response. Returns `None` when the response scripted no chunks, in which case
456/// the streaming pump falls back to a single full-text delta.
457pub(crate) fn take_mock_stream_chunks() -> Option<Vec<String>> {
458    LLM_MOCK_STREAM_CHUNKS.with(|slot| slot.borrow_mut().take())
459}
460
461fn cli_llm_mock_scopes() -> MutexGuard<'static, BTreeMap<u64, CliLlmMockState>> {
462    CLI_LLM_MOCK_SCOPES
463        .lock()
464        .unwrap_or_else(|poisoned| poisoned.into_inner())
465}
466
467fn next_cli_llm_mock_scope_id() -> u64 {
468    CLI_LLM_MOCK_NEXT_SCOPE.fetch_add(1, Ordering::Relaxed)
469}
470
471pub(crate) fn current_cli_llm_mock_scope() -> Option<u64> {
472    current_cli_llm_mock_lease().map(|scope| scope.0)
473}
474
475fn current_cli_llm_mock_lease() -> Option<Arc<CliLlmMockLease>> {
476    with_mock_state(|state| state.cli_scope.clone())
477}
478
479fn install_cli_llm_mock_scope(state: CliLlmMockState) {
480    clear_cli_llm_mock_mode();
481    let scope = next_cli_llm_mock_scope_id();
482    cli_llm_mock_scopes().insert(scope, state);
483    with_mock_state_mut(|state| state.cli_scope = Some(Arc::new(CliLlmMockLease(scope))));
484}
485
486/// Test-only escape hatch for Rust unit tests that seed the legacy queue
487/// directly. Runtime callers use the typed fixture install or inline builtin.
488#[cfg(test)]
489pub(crate) fn push_llm_mock(mock: LlmMock) {
490    with_mock_state_mut(|state| state.builtin_queue.push_v0(mock));
491}
492
493/// Append a legacy inline v0 entry. A whole-document v1 fixture owns its
494/// queue shape, so mixing inline entries into it would silently change the
495/// document's declared contract.
496pub(crate) fn push_inline_llm_mock(mock: LlmMock) -> Result<(), String> {
497    with_mock_state_mut(|state| {
498        let queue = &mut state.builtin_queue;
499        if queue.schema_version() > 0 {
500            return Err(
501                "cannot append harness.llm.mock_enqueue() entries to an active versioned fixture; clear or load one complete document"
502                    .to_string(),
503            );
504        }
505        queue.push_v0(mock);
506        Ok(())
507    })
508}
509
510/// Atomically replace the builtin fixture store after the whole document has
511/// already parsed. A parse failure never reaches this function, preserving the
512/// active fixture exactly as it was.
513pub(crate) fn install_builtin_llm_mock_fixture(fixture: LlmMockFixture) -> LlmMockFixtureReceipt {
514    let queue = MockQueue::from_fixture(fixture);
515    let receipt = LlmMockFixtureReceipt {
516        schema_version: queue.schema_version(),
517        strict_scopes: queue.strict_scopes(),
518        count: queue.count(),
519        scopes: queue.scopes(),
520        warnings: queue.warnings().to_vec(),
521    };
522    with_mock_state_mut(|state| state.builtin_queue = queue);
523    receipt
524}
525
526pub(crate) fn get_llm_mock_calls() -> Vec<LlmMockCall> {
527    with_mock_state(|state| state.calls.clone())
528}
529
530/// Return the consumption receipts recorded since the last reset/scope swap.
531pub(crate) fn get_llm_mock_receipts() -> Vec<MockConsumptionReceipt> {
532    with_mock_state(|state| state.receipts.clone())
533}
534
535fn record_mock_receipt(session_id: Option<&str>, receipt: MockConsumptionReceipt) {
536    if receipt.matched {
537        if let Some(session_id) = session_id.filter(|id| !id.is_empty()) {
538            super::agent_runtime::emit_agent_event_sync(
539                &crate::agent_events::AgentEvent::TypedCheckpoint {
540                    session_id: session_id.to_string(),
541                    checkpoint: serde_json::json!({
542                        "kind": "llm_mock_fixture_consumption",
543                        "schema": "harn.llm_mock_fixture_consumption.v1",
544                        "id": receipt.id,
545                        "requested_scope": receipt.requested_scope,
546                        "resolved_scope": receipt.resolved_scope,
547                        "consume": receipt.consume,
548                        "fell_through": receipt.fell_through,
549                        "remaining": receipt.remaining,
550                    }),
551                },
552            );
553        }
554    }
555    with_mock_state_mut(|state| state.receipts.push(receipt));
556}
557
558pub(crate) fn builtin_llm_mock_snapshot() -> serde_json::Value {
559    with_mock_state(|state| {
560        let queue = &state.builtin_queue;
561        serde_json::json!({
562            "schema": "harn.llm_mock_fixture_queue.v1",
563            "schema_version": queue.schema_version(),
564            "strict_scopes": queue.strict_scopes(),
565            "queue_remaining": queue.queue_remaining(),
566            "warnings": queue.warnings(),
567        })
568    })
569}
570
571pub(crate) fn builtin_llm_mock_active() -> bool {
572    with_mock_state(|state| state.builtin_queue.is_active())
573}
574
575pub(crate) fn builtin_llm_mock_strict_scopes() -> bool {
576    with_mock_state(|state| state.builtin_queue.strict_scopes())
577}
578
579pub(crate) fn reset_llm_mock_state() {
580    with_mock_state_mut(|state| {
581        state.cli_scope = None;
582        state.builtin_queue = MockQueue::default();
583        state.calls.clear();
584        state.prompt_cache.clear();
585        state.scopes.clear();
586        state.receipts.clear();
587    });
588}
589
590/// Save the current builtin LLM mock queue and recorded-calls list, then
591/// start a fresh empty scope. Paired with `pop_llm_mock_scope`. Backs
592/// the `with_llm_mocks` helper in `std/testing` so tests reliably
593/// roll back to the prior state, including when the body throws.
594pub(crate) fn push_llm_mock_scope() {
595    with_mock_state_mut(|state| {
596        let fixture = std::mem::take(&mut state.builtin_queue);
597        let calls = std::mem::take(&mut state.calls);
598        let cache = std::mem::take(&mut state.prompt_cache);
599        let receipts = std::mem::take(&mut state.receipts);
600        state.scopes.push((fixture, calls, cache, receipts));
601    });
602}
603
604/// Restore the most recently pushed builtin LLM mock scope. Returns
605/// `false` when there is nothing to pop, so the builtin can surface a
606/// clear "imbalanced scope" error rather than silently corrupting
607/// state. CLI-installed mocks are intentionally untouched: they are an
608/// outer harness and should not flicker on each per-test scope swap.
609pub(crate) fn pop_llm_mock_scope() -> bool {
610    with_mock_state_mut(|state| match state.scopes.pop() {
611        Some((fixture, calls, cache, receipts)) => {
612            state.builtin_queue = fixture;
613            state.calls = calls;
614            state.prompt_cache = cache;
615            state.receipts = receipts;
616            true
617        }
618        None => false,
619    })
620}
621
622pub fn clear_cli_llm_mock_mode() {
623    with_mock_state_mut(|state| state.cli_scope = None);
624}
625
626pub fn install_cli_llm_mocks(mocks: Vec<LlmMock>) {
627    install_cli_llm_mock_scope(CliLlmMockState {
628        mode: CliLlmMockMode::Replay,
629        queue: MockQueue::from_fixture(LlmMockFixture {
630            schema_version: 0,
631            strict_scopes: false,
632            mocks,
633            warnings: Vec::new(),
634        }),
635        recordings: Vec::new(),
636    });
637}
638
639/// Install a parsed fixture, honoring its file-level `strictScopes` header.
640pub fn install_cli_llm_mock_fixture(fixture: LlmMockFixture) {
641    install_cli_llm_mock_scope(CliLlmMockState {
642        mode: CliLlmMockMode::Replay,
643        queue: MockQueue::from_fixture(fixture),
644        recordings: Vec::new(),
645    });
646}
647
648pub fn enable_cli_llm_mock_recording() {
649    install_cli_llm_mock_scope(CliLlmMockState {
650        mode: CliLlmMockMode::Record,
651        queue: MockQueue::default(),
652        recordings: Vec::new(),
653    });
654}
655
656pub fn take_cli_llm_recordings() -> Vec<LlmMock> {
657    let Some(scope) = current_cli_llm_mock_scope() else {
658        return Vec::new();
659    };
660    cli_llm_mock_scopes()
661        .get_mut(&scope)
662        .map(|state| std::mem::take(&mut state.recordings))
663        .unwrap_or_default()
664}
665
666pub(crate) fn cli_llm_mock_replay_active() -> bool {
667    cli_llm_mock_replay_active_for_scope(current_cli_llm_mock_scope())
668}
669
670pub(crate) fn cli_llm_mock_replay_active_for_scope(scope: Option<u64>) -> bool {
671    let Some(scope) = scope else {
672        return false;
673    };
674    cli_llm_mock_scopes()
675        .get(&scope)
676        .is_some_and(|state| state.mode == CliLlmMockMode::Replay)
677}
678
679fn record_llm_mock_call(request: &super::api::LlmRequestPayload) {
680    with_mock_state_mut(|state| {
681        state.calls.push(LlmMockCall {
682            mock_scope: request
683                .mock_scope
684                .as_deref()
685                .unwrap_or(DEFAULT_MOCK_SCOPE)
686                .to_string(),
687            api_mode: request.api_mode.as_str().to_string(),
688            messages: request.messages.clone(),
689            system: request.system.clone(),
690            tools: request.native_tools.clone(),
691            provider_tools: if request.provider_tools.is_empty() {
692                None
693            } else {
694                Some(request.provider_tools.clone())
695            },
696            tool_choice: request.tool_choice.clone(),
697            output_format: serde_json::to_value(&request.output_format).unwrap_or_else(|_| {
698                serde_json::json!({
699                    "kind": "text"
700                })
701            }),
702            thinking: serde_json::to_value(&request.thinking).unwrap_or_else(|_| {
703                serde_json::json!({
704                    "mode": "disabled"
705                })
706            }),
707            max_tokens: request.max_tokens,
708            temperature: request.temperature,
709            top_p: request.top_p,
710            top_k: request.top_k,
711            stop: request.stop.clone(),
712            seed: request.seed,
713            frequency_penalty: request.frequency_penalty,
714            presence_penalty: request.presence_penalty,
715            previous_response_id: request.previous_response_id.clone(),
716            store: request.store,
717            background: request.background,
718            truncation: request.truncation.clone(),
719            compact: request.compact,
720            include: request.include.clone(),
721            max_tool_calls: request.max_tool_calls,
722            prefill: request.prefill.clone(),
723        });
724    });
725}
726
727/// Build an LlmResult from a matched mock.
728fn build_mock_result(mock: &LlmMock, last_msg_len: usize) -> LlmResult {
729    // A mock may script an ordered list of visible-text chunks for streaming
730    // callers. Derive the flat `text` from their concatenation when `text` was
731    // left empty, so the non-streaming result and the streamed transcript agree
732    // byte-for-byte, and stash the chunks for the streaming delta pump.
733    let effective_text = if !mock.stream_chunks.is_empty() && mock.text.is_empty() {
734        mock.stream_chunks.concat()
735    } else {
736        mock.text.clone()
737    };
738    set_mock_stream_chunks(if mock.stream_chunks.is_empty() {
739        None
740    } else {
741        Some(mock.stream_chunks.clone())
742    });
743    let mock = &LlmMock {
744        text: effective_text,
745        ..mock.clone()
746    };
747    let (tool_calls, blocks) = if let Some(blocks) = &mock.blocks {
748        (mock.tool_calls.clone(), blocks.clone())
749    } else {
750        let mut blocks = Vec::new();
751
752        if !mock.text.is_empty() {
753            blocks.push(serde_json::json!({
754                "type": "output_text",
755                "text": mock.text,
756                "visibility": "public",
757            }));
758        }
759
760        let mut tool_calls = Vec::new();
761        for (i, tc) in mock.tool_calls.iter().enumerate() {
762            let id = format!("mock_call_{}", i + 1);
763            let name = tc.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
764            let arguments = tc
765                .get("arguments")
766                .cloned()
767                .unwrap_or(serde_json::json!({}));
768            tool_calls.push(serde_json::json!({
769                "id": id,
770                "type": "tool_call",
771                "name": name,
772                "arguments": arguments,
773            }));
774            blocks.push(serde_json::json!({
775                "type": "tool_call",
776                "id": id,
777                "name": name,
778                "arguments": arguments,
779                "visibility": "internal",
780            }));
781        }
782
783        (tool_calls, blocks)
784    };
785
786    LlmResult {
787        attempts: Default::default(),
788        text_projection: None,
789        served_fast: false,
790        text: mock.text.clone(),
791        raw_tool_calls: if mock.raw_tool_calls.is_empty() {
792            Vec::new()
793        } else {
794            mock.raw_tool_calls.clone()
795        },
796        tool_calls,
797        input_tokens: mock.input_tokens.unwrap_or(last_msg_len as i64),
798        output_tokens: mock.output_tokens.unwrap_or(30),
799        cache_read_tokens: mock.cache_read_tokens.unwrap_or(0),
800        cache_write_tokens: mock.cache_write_tokens.unwrap_or(0),
801        cache_supported: true,
802        model: mock.model.clone(),
803        provider: mock.provider.clone().unwrap_or_else(|| "mock".to_string()),
804        thinking: mock.thinking.clone(),
805        thinking_summary: mock.thinking_summary.clone(),
806        stop_reason: mock.stop_reason.clone(),
807        blocks,
808        logprobs: mock.logprobs.clone(),
809        telemetry: ProviderTelemetry::default(),
810    }
811}
812
813// Mock prompt patterns match free prose, where `?`/`[`/`{` are ordinary
814// characters — only `*` is a wildcard. The shared prose matcher keeps that
815// contract (`*`-only ordered segments).
816fn collect_mock_match_strings(value: &serde_json::Value, out: &mut Vec<String>) {
817    match value {
818        serde_json::Value::String(text) if !text.is_empty() => out.push(text.clone()),
819        serde_json::Value::String(_) => {}
820        serde_json::Value::Array(items) => {
821            for item in items {
822                collect_mock_match_strings(item, out);
823            }
824        }
825        serde_json::Value::Object(map) => {
826            for value in map.values() {
827                collect_mock_match_strings(value, out);
828            }
829        }
830        _ => {}
831    }
832}
833
834fn mock_match_text(messages: &[serde_json::Value]) -> String {
835    let mut parts = Vec::new();
836    for message in messages {
837        collect_mock_match_strings(message, &mut parts);
838    }
839    parts.join("\n")
840}
841
842fn mock_last_prompt_text(messages: &[serde_json::Value]) -> String {
843    for message in messages.iter().rev() {
844        let Some(content) = message.get("content") else {
845            continue;
846        };
847        let mut parts = Vec::new();
848        collect_mock_match_strings(content, &mut parts);
849        let text = parts.join("\n");
850        if !text.trim().is_empty() {
851            return text;
852        }
853    }
854    String::new()
855}
856
857fn mock_prompt_cache_key(
858    model: &str,
859    messages: &[serde_json::Value],
860    system: Option<&str>,
861    mock_scope: &str,
862) -> String {
863    serde_json::to_string(&serde_json::json!({
864        "model": model,
865        "system": system,
866        "messages": messages,
867        "mock_scope": mock_scope,
868    }))
869    .unwrap_or_default()
870}
871
872fn apply_mock_prompt_cache(result: &mut LlmResult, cache_key: &str) {
873    if result.cache_read_tokens > 0 || result.cache_write_tokens > 0 {
874        return;
875    }
876    let cache_tokens = result.input_tokens.max(0);
877    if cache_tokens == 0 {
878        return;
879    }
880    let cache_hit = with_mock_state_mut(|state| {
881        if state.prompt_cache.contains(cache_key) {
882            true
883        } else {
884            state.prompt_cache.insert(cache_key.to_string());
885            false
886        }
887    });
888    if cache_hit {
889        result.cache_read_tokens = cache_tokens;
890    } else {
891        result.cache_write_tokens = cache_tokens;
892    }
893}
894
895/// Convert a mock's `error` payload into the `VmError` that the
896/// provider path would have raised, so classification, retry, and
897/// `error_category` all behave identically to a real failure.
898fn mock_error_to_vm_error(err: &MockError) -> VmError {
899    let message = mock_error_message(err);
900    if err.has_provider_envelope() {
901        let classified = super::api::classify_llm_error(err.category.clone(), &message);
902        let mut dict = BTreeMap::new();
903        dict.put_str("category", err.category.as_str());
904        dict.put_str(
905            "kind",
906            err.kind
907                .as_deref()
908                .unwrap_or_else(|| classified.kind.as_str()),
909        );
910        dict.put_str(
911            "reason",
912            err.reason
913                .as_deref()
914                .unwrap_or_else(|| classified.reason.as_str()),
915        );
916        dict.put_str("message", message);
917        if let Some(status) = err.status {
918            dict.insert("status".to_string(), VmValue::Int(i64::from(status)));
919        }
920        if let Some(retry_after_ms) = err.retry_after_ms {
921            dict.insert(
922                "retry_after_ms".to_string(),
923                VmValue::Int(retry_after_ms as i64),
924            );
925        }
926        return VmError::Thrown(VmValue::dict(dict));
927    }
928
929    VmError::CategorizedError {
930        message,
931        category: err.category.clone(),
932    }
933}
934
935fn mock_error_message(err: &MockError) -> String {
936    // Embed legacy category-only retry hints into the message so the
937    // same parser that handles live provider headers populates
938    // `retry_after_ms` on the final thrown dict.
939    let Some(ms) = err.retry_after_ms else {
940        return err.message.clone();
941    };
942    if err.has_provider_envelope() {
943        return err.message.clone();
944    }
945    let secs = (ms as f64 / 1000.0).max(0.0);
946    let sep = if err.message.is_empty() || err.message.ends_with('\n') {
947        ""
948    } else {
949        "\n"
950    };
951    format!("{}{sep}retry-after: {secs}\n", err.message)
952}
953
954/// A matched entry: the response (or error) to serve plus the consumption
955/// receipt produced by the shared queue store.
956struct ScopedMatch {
957    outcome: Result<LlmResult, VmError>,
958    receipt: MockConsumptionReceipt,
959}
960
961fn build_scoped_match(selected: QueueMatch, match_text: &str) -> ScopedMatch {
962    let QueueMatch { mock, receipt } = selected;
963    let outcome = match &mock.error {
964        Some(err) => Err(mock_error_to_vm_error(err)),
965        None => Ok(build_mock_result(&mock, match_text.len())),
966    };
967    ScopedMatch { outcome, receipt }
968}
969
970fn try_match_builtin_mock(scope: &str, match_text: &str) -> Option<ScopedMatch> {
971    with_mock_state_mut(|state| {
972        state
973            .builtin_queue
974            .match_request(scope, match_text)
975            .map(|selected| build_scoped_match(selected, match_text))
976    })
977}
978
979fn try_match_cli_mock(
980    cli_scope: Option<u64>,
981    scope: &str,
982    match_text: &str,
983) -> Option<ScopedMatch> {
984    let cli_scope = cli_scope?;
985    let mut scopes = cli_llm_mock_scopes();
986    let state = scopes.get_mut(&cli_scope)?;
987    if state.mode != CliLlmMockMode::Replay {
988        return None;
989    }
990    state
991        .queue
992        .match_request(scope, match_text)
993        .map(|selected| build_scoped_match(selected, match_text))
994}
995
996pub(crate) fn record_cli_llm_result(request: &super::api::LlmRequestPayload, result: &LlmResult) {
997    record_unified_tape_llm_call(result);
998    let Some(scope) = request.cli_llm_mock_scope else {
999        return;
1000    };
1001    let mut scopes = cli_llm_mock_scopes();
1002    let Some(state) = scopes.get_mut(&scope) else {
1003        return;
1004    };
1005    if state.mode != CliLlmMockMode::Record {
1006        return;
1007    }
1008    state.recordings.push(LlmMock {
1009        text: result.text.clone(),
1010        tool_calls: result.tool_calls.clone(),
1011        raw_tool_calls: result.raw_tool_calls.clone(),
1012        match_pattern: None,
1013        scope: request
1014            .mock_scope
1015            .clone()
1016            .unwrap_or_else(|| DEFAULT_MOCK_SCOPE.to_string()),
1017        entry_id: String::new(),
1018        sticky: false,
1019        input_tokens: Some(result.input_tokens),
1020        output_tokens: Some(result.output_tokens),
1021        cache_read_tokens: Some(result.cache_read_tokens),
1022        cache_write_tokens: Some(result.cache_write_tokens),
1023        thinking: result.thinking.clone(),
1024        thinking_summary: result.thinking_summary.clone(),
1025        stop_reason: result.stop_reason.clone(),
1026        model: result.model.clone(),
1027        provider: Some(result.provider.clone()),
1028        blocks: Some(result.blocks.clone()),
1029        logprobs: result.logprobs.clone(),
1030        error: None,
1031        stream_chunks: Vec::new(),
1032    });
1033}
1034
1035/// Append an `LlmCall` record to the unified-tape recorder when one is
1036/// active. The request digest is built from the most recently recorded
1037/// `LlmMockCall` so the same hashing surface used for fixture matching
1038/// drives the fidelity oracle's request comparison; falls back to a
1039/// hash of the response text alone when no matching call is on record
1040/// (e.g. when `record_llm_mock_call` was bypassed).
1041fn record_unified_tape_llm_call(result: &LlmResult) {
1042    if crate::testbench::tape::active_recorder().is_none() {
1043        return;
1044    }
1045    let response_json = serde_json::to_vec(result).unwrap_or_else(|_| Vec::new());
1046    let request_digest = with_mock_state(|state| state.calls.last().cloned())
1047        .map(|call| {
1048            let mut request = serde_json::Map::new();
1049            request.insert("messages".to_string(), serde_json::json!(call.messages));
1050            request.insert("system".to_string(), serde_json::json!(call.system));
1051            request.insert("tools".to_string(), serde_json::json!(call.tools));
1052            request.insert(
1053                "tool_choice".to_string(),
1054                serde_json::json!(call.tool_choice),
1055            );
1056            request.insert("thinking".to_string(), serde_json::json!(call.thinking));
1057            if call.mock_scope != DEFAULT_MOCK_SCOPE {
1058                request.insert("mock_scope".to_string(), serde_json::json!(call.mock_scope));
1059            }
1060            request.insert("model".to_string(), serde_json::json!(result.model));
1061            if call.api_mode != "chat_completions" {
1062                request.insert("api_mode".to_string(), serde_json::json!(call.api_mode));
1063            }
1064            if call.provider_tools.is_some() {
1065                request.insert(
1066                    "provider_tools".to_string(),
1067                    serde_json::json!(call.provider_tools),
1068                );
1069            }
1070            if call
1071                .output_format
1072                .get("kind")
1073                .and_then(|value| value.as_str())
1074                != Some("text")
1075            {
1076                request.insert(
1077                    "output_format".to_string(),
1078                    serde_json::json!(call.output_format),
1079                );
1080            }
1081            if call.previous_response_id.is_some() {
1082                request.insert(
1083                    "previous_response_id".to_string(),
1084                    serde_json::json!(call.previous_response_id),
1085                );
1086            }
1087            if call.store.is_some() {
1088                request.insert("store".to_string(), serde_json::json!(call.store));
1089            }
1090            if call.background.is_some() {
1091                request.insert("background".to_string(), serde_json::json!(call.background));
1092            }
1093            if call.truncation.is_some() {
1094                request.insert("truncation".to_string(), serde_json::json!(call.truncation));
1095            }
1096            if call.compact.is_some() {
1097                request.insert("compact".to_string(), serde_json::json!(call.compact));
1098            }
1099            if call.include.is_some() {
1100                request.insert("include".to_string(), serde_json::json!(call.include));
1101            }
1102            if call.max_tool_calls.is_some() {
1103                request.insert(
1104                    "max_tool_calls".to_string(),
1105                    serde_json::json!(call.max_tool_calls),
1106                );
1107            }
1108            if call.prefill.is_some() {
1109                request.insert("prefill".to_string(), serde_json::json!(call.prefill));
1110            }
1111            let serialized = crate::canonical_json::to_vec(&serde_json::Value::Object(request));
1112            crate::testbench::tape::content_hash(&serialized)
1113        })
1114        .unwrap_or_else(|| {
1115            // Fall back to hashing the response — keeps fidelity comparable
1116            // across runs even when the request surface wasn't captured.
1117            crate::testbench::tape::content_hash(result.text.as_bytes())
1118        });
1119    crate::testbench::tape::with_active_recorder(|recorder| {
1120        let response = recorder.payload_from_bytes(response_json);
1121        Some(crate::testbench::tape::TapeRecordKind::LlmCall {
1122            request_digest,
1123            response,
1124        })
1125    });
1126}
1127
1128fn unmatched_cli_prompt_error(match_text: &str) -> VmError {
1129    let mut snippet: String = match_text.chars().take(200).collect();
1130    if match_text.chars().count() > 200 {
1131        snippet.push_str("...");
1132    }
1133    VmError::Runtime(format!("No --llm-mock fixture matched prompt: {snippet:?}"))
1134}
1135
1136fn unmatched_builtin_prompt_error(match_text: &str) -> VmError {
1137    let mut snippet: String = match_text.chars().take(200).collect();
1138    if match_text.chars().count() > 200 {
1139        snippet.push_str("...");
1140    }
1141    VmError::Runtime(format!(
1142        "No llm_mock fixture matched prompt in a strict scope: {snippet:?}"
1143    ))
1144}
1145
1146/// Set LLM replay mode (record/replay) and fixture directory.
1147pub fn set_replay_mode(mode: LlmReplayMode, fixture_dir: &str) {
1148    LLM_REPLAY_MODE.with(|v| *v.borrow_mut() = mode);
1149    LLM_FIXTURE_DIR.with(|v| *v.borrow_mut() = fixture_dir.to_string());
1150}
1151
1152pub(crate) fn get_replay_mode() -> LlmReplayMode {
1153    LLM_REPLAY_MODE.with(|v| *v.borrow())
1154}
1155
1156pub(crate) fn get_fixture_dir() -> String {
1157    LLM_FIXTURE_DIR.with(|v| v.borrow().clone())
1158}
1159
1160/// Hash a request for fixture file naming using canonical JSON serialization.
1161pub(crate) fn fixture_hash(
1162    model: &str,
1163    messages: &[serde_json::Value],
1164    system: Option<&str>,
1165    mock_scope: Option<&str>,
1166) -> String {
1167    use std::hash::{Hash, Hasher};
1168    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1169    model.hash(&mut hasher);
1170    // Canonical JSON hashing is stable across Debug-format changes.
1171    serde_json::to_string(messages)
1172        .unwrap_or_default()
1173        .hash(&mut hasher);
1174    system.hash(&mut hasher);
1175    if mock_scope.is_some_and(|scope| scope != DEFAULT_MOCK_SCOPE) {
1176        mock_scope.hash(&mut hasher);
1177    }
1178    format!("{:016x}", hasher.finish())
1179}
1180
1181pub(crate) fn fixture_hash_for_request(request: &super::api::LlmRequestPayload) -> String {
1182    fixture_hash(
1183        &request.model,
1184        &request.messages,
1185        request.system.as_deref(),
1186        request.mock_scope.as_deref(),
1187    )
1188}
1189
1190pub(crate) fn save_fixture(hash: &str, result: &LlmResult) {
1191    let dir = get_fixture_dir();
1192    if dir.is_empty() {
1193        return;
1194    }
1195    let _ = std::fs::create_dir_all(&dir);
1196    let path = format!("{dir}/{hash}.json");
1197    let json = serde_json::json!({
1198        "text": result.text,
1199        "tool_calls": result.tool_calls,
1200        "raw_tool_calls": result.raw_tool_calls,
1201        "input_tokens": result.input_tokens,
1202        "output_tokens": result.output_tokens,
1203        "cache_read_tokens": result.cache_read_tokens,
1204        "cache_write_tokens": result.cache_write_tokens,
1205        "model": result.model,
1206        "provider": result.provider,
1207        "thinking": result.thinking,
1208        "thinking_summary": result.thinking_summary,
1209        "stop_reason": result.stop_reason,
1210        "blocks": result.blocks,
1211        "logprobs": result.logprobs,
1212    });
1213    let _ = std::fs::write(
1214        &path,
1215        serde_json::to_string_pretty(&json).unwrap_or_default(),
1216    );
1217}
1218
1219pub(crate) fn load_fixture(hash: &str) -> Option<LlmResult> {
1220    let dir = get_fixture_dir();
1221    if dir.is_empty() {
1222        return None;
1223    }
1224    let path = format!("{dir}/{hash}.json");
1225    let content = std::fs::read_to_string(&path).ok()?;
1226    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
1227    Some(LlmResult {
1228        attempts: Default::default(),
1229        text_projection: None,
1230        served_fast: false,
1231        text: json["text"].as_str().unwrap_or("").to_string(),
1232        tool_calls: json["tool_calls"].as_array().cloned().unwrap_or_default(),
1233        raw_tool_calls: RawProviderToolCall::array_from_value(&json["raw_tool_calls"]).ok()?,
1234        input_tokens: json["input_tokens"].as_i64().unwrap_or(0),
1235        output_tokens: json["output_tokens"].as_i64().unwrap_or(0),
1236        cache_read_tokens: json["cache_read_tokens"].as_i64().unwrap_or(0),
1237        cache_write_tokens: json["cache_write_tokens"]
1238            .as_i64()
1239            .or_else(|| json["cache_creation_input_tokens"].as_i64())
1240            .unwrap_or(0),
1241        cache_supported: json["cache_supported"].as_bool().unwrap_or(true),
1242        model: json["model"].as_str().unwrap_or("").to_string(),
1243        provider: json["provider"].as_str().unwrap_or("mock").to_string(),
1244        thinking: json["thinking"].as_str().map(|s| s.to_string()),
1245        thinking_summary: json["thinking_summary"].as_str().map(|s| s.to_string()),
1246        stop_reason: json["stop_reason"].as_str().map(|s| s.to_string()),
1247        blocks: json["blocks"].as_array().cloned().unwrap_or_default(),
1248        logprobs: json["logprobs"].as_array().cloned().unwrap_or_default(),
1249        telemetry: serde_json::from_value(json["telemetry"].clone()).unwrap_or_default(),
1250    })
1251}
1252
1253/// Generate stub argument values for required parameters in a tool schema.
1254/// This makes mock tool calls realistic — a real model would always fill
1255/// required fields, so the mock should too.
1256fn mock_required_args(tool_schema: &serde_json::Value) -> serde_json::Value {
1257    let mut args = serde_json::Map::new();
1258    // Anthropic: {name, input_schema: {properties, required}}
1259    // OpenAI:    {function: {name, parameters: {properties, required}}}
1260    // Harn VM:   {parameters: {name: {type, required}}}  (from tool_define)
1261    let input_schema = tool_schema
1262        .get("input_schema")
1263        .or_else(|| tool_schema.get("inputSchema"))
1264        .or_else(|| {
1265            tool_schema
1266                .get("function")
1267                .and_then(|f| f.get("parameters"))
1268        })
1269        .or_else(|| tool_schema.get("parameters"));
1270    let Some(schema) = input_schema else {
1271        return serde_json::Value::Object(args);
1272    };
1273    let required: std::collections::BTreeSet<String> = schema
1274        .get("required")
1275        .and_then(|r| r.as_array())
1276        .map(|arr| {
1277            arr.iter()
1278                .filter_map(|v| v.as_str().map(|s| s.to_string()))
1279                .collect()
1280        })
1281        .unwrap_or_default();
1282    if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
1283        for (name, prop) in props {
1284            if !required.contains(name) {
1285                continue;
1286            }
1287            let ty = prop
1288                .get("type")
1289                .and_then(|t| t.as_str())
1290                .unwrap_or("string");
1291            let placeholder = match ty {
1292                "integer" => serde_json::json!(0),
1293                "number" => serde_json::json!(0.0),
1294                "boolean" => serde_json::json!(false),
1295                "array" => serde_json::json!([]),
1296                "object" => serde_json::json!({}),
1297                _ => serde_json::json!(""),
1298            };
1299            args.insert(name.clone(), placeholder);
1300        }
1301    }
1302    serde_json::Value::Object(args)
1303}
1304
1305fn mock_tool_name(tool: &serde_json::Value) -> Option<&str> {
1306    tool.get("name")
1307        .or_else(|| {
1308            tool.get("function")
1309                .and_then(|function| function.get("name"))
1310        })
1311        .and_then(|name| name.as_str())
1312}
1313
1314fn mock_auto_tool_candidate(tools: &[serde_json::Value]) -> Option<&serde_json::Value> {
1315    tools
1316        .iter()
1317        .find(|tool| mock_tool_name(tool) != Some("agent_await_resumption"))
1318}
1319
1320/// Mock LLM provider -- deterministic responses for testing without API keys.
1321/// When configurable mocks have been registered via `harness.llm.mock_enqueue()`, those are
1322/// checked first (FIFO queue, then pattern matching). Falls through to the
1323/// default deterministic behavior when no mocks match.
1324pub(crate) fn mock_llm_response(
1325    request: &super::api::LlmRequestPayload,
1326) -> Result<LlmResult, VmError> {
1327    record_llm_mock_call(request);
1328    // Reset per-call so a response that scripts no chunks (auto tool calls,
1329    // generated prose, error mocks) never inherits a prior mock's chunks.
1330    set_mock_stream_chunks(None);
1331
1332    let messages = &request.messages;
1333    let system = request.system.as_deref();
1334    let match_text = mock_match_text(messages);
1335    let prompt_text = mock_last_prompt_text(messages);
1336    let requested_scope = request.mock_scope.as_deref().unwrap_or(DEFAULT_MOCK_SCOPE);
1337    let cache_key = mock_prompt_cache_key(&request.model, messages, system, requested_scope);
1338
1339    if let Some(matched) =
1340        try_match_cli_mock(request.cli_llm_mock_scope, requested_scope, &match_text)
1341    {
1342        record_mock_receipt(request.session_id.as_deref(), matched.receipt);
1343        return matched.outcome.map(|mut result| {
1344            if request.cache {
1345                apply_mock_prompt_cache(&mut result, &cache_key);
1346            }
1347            result
1348        });
1349    }
1350
1351    if let Some(matched) = try_match_builtin_mock(requested_scope, &match_text) {
1352        record_mock_receipt(request.session_id.as_deref(), matched.receipt);
1353        return matched.outcome.map(|mut result| {
1354            if request.cache {
1355                apply_mock_prompt_cache(&mut result, &cache_key);
1356            }
1357            result
1358        });
1359    }
1360
1361    // No entry matched anywhere this call could reach. Record one miss receipt
1362    // when a fixture set is active so a strict-scope hard miss stays assertable.
1363    if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) || builtin_llm_mock_active()
1364    {
1365        let receipt = if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) {
1366            let scopes = cli_llm_mock_scopes();
1367            scopes
1368                .get(&request.cli_llm_mock_scope.unwrap())
1369                .map(|state| state.queue.miss_receipt(requested_scope))
1370                .unwrap_or_else(|| MockConsumptionReceipt::miss(requested_scope, 0))
1371        } else {
1372            with_mock_state(|state| state.builtin_queue.miss_receipt(requested_scope))
1373        };
1374        record_mock_receipt(request.session_id.as_deref(), receipt);
1375    }
1376
1377    if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) {
1378        return Err(unmatched_cli_prompt_error(&match_text));
1379    }
1380    if builtin_llm_mock_strict_scopes() {
1381        return Err(unmatched_builtin_prompt_error(&match_text));
1382    }
1383
1384    // Generate a mock tool call for the first tool, filling required
1385    // params with placeholders so the call passes schema validation.
1386    if let Some(tools) = request.native_tools.as_deref() {
1387        if let Some(first_tool) = mock_auto_tool_candidate(tools) {
1388            let tool_name = mock_tool_name(first_tool).unwrap_or("unknown");
1389            let mock_args = mock_required_args(first_tool);
1390            let mut result = LlmResult {
1391                attempts: Default::default(),
1392                text_projection: None,
1393                served_fast: false,
1394                text: String::new(),
1395                tool_calls: vec![serde_json::json!({
1396                        "id": "mock_call_1",
1397                        "type": "tool_call",
1398                        "name": tool_name,
1399                "arguments": mock_args
1400                })],
1401                raw_tool_calls: Vec::new(),
1402                input_tokens: prompt_text.len() as i64,
1403                output_tokens: 20,
1404                cache_read_tokens: 0,
1405                cache_write_tokens: 0,
1406                cache_supported: true,
1407                model: request.model.clone(),
1408                provider: "mock".to_string(),
1409                thinking: None,
1410                thinking_summary: None,
1411                stop_reason: None,
1412                blocks: vec![serde_json::json!({
1413                    "type": "tool_call",
1414                    "id": "mock_call_1",
1415                    "name": tool_name,
1416                    "arguments": mock_args,
1417                    "visibility": "internal",
1418                })],
1419                logprobs: Vec::new(),
1420                telemetry: ProviderTelemetry::default(),
1421            };
1422            if request.cache {
1423                apply_mock_prompt_cache(&mut result, &cache_key);
1424            }
1425            return Ok(result);
1426        }
1427    }
1428
1429    // Preserve the historical auto-complete behavior for tagged text-tool
1430    // prompts only. Bare `##DONE##` in no-tool/native prompts changes
1431    // loop semantics by completing runs that used to exhaust budget unless
1432    // a fixture explicitly returned the sentinel.
1433    let tagged_done = system.is_some_and(|s| s.contains("<done>"));
1434
1435    let prose_body = if prompt_text.is_empty() {
1436        "Mock LLM response".to_string()
1437    } else {
1438        let word_count = prompt_text.split_whitespace().count();
1439        format!(
1440            "Mock response to {word_count}-word prompt: {}",
1441            prompt_text.chars().take(100).collect::<String>()
1442        )
1443    };
1444    let response = if tagged_done {
1445        format!("<assistant_prose>{prose_body}</assistant_prose>\n<done>##DONE##</done>")
1446    } else {
1447        prose_body
1448    };
1449
1450    let mut result = LlmResult {
1451        attempts: Default::default(),
1452        text_projection: None,
1453        served_fast: false,
1454        text: response.clone(),
1455        tool_calls: vec![],
1456        raw_tool_calls: Vec::new(),
1457        input_tokens: prompt_text.len() as i64,
1458        output_tokens: 30,
1459        cache_read_tokens: 0,
1460        cache_write_tokens: 0,
1461        cache_supported: true,
1462        model: request.model.clone(),
1463        provider: "mock".to_string(),
1464        thinking: None,
1465        thinking_summary: None,
1466        stop_reason: None,
1467        blocks: vec![serde_json::json!({
1468            "type": "output_text",
1469            "text": response,
1470            "visibility": "public",
1471        })],
1472        logprobs: Vec::new(),
1473        telemetry: ProviderTelemetry::default(),
1474    };
1475    if request.cache {
1476        apply_mock_prompt_cache(&mut result, &cache_key);
1477    }
1478    Ok(result)
1479}
1480
1481/// Take all recorded tool calls, leaving the buffer empty.
1482pub fn drain_tool_recordings() -> Vec<ToolCallRecord> {
1483    TOOL_RECORDINGS.with(|v| std::mem::take(&mut *v.borrow_mut()))
1484}
1485
1486#[cfg(test)]
1487#[path = "mock_tests.rs"]
1488mod tests;