Skip to main content

harn_vm/llm/
fake.rs

1//! Deterministic fake LLM provider for streaming/tool-use/error tests.
2//!
3//! `FakeLlmProvider` plays back a pre-scripted sequence of streamed events
4//! (text deltas, tool-call deltas, stop reasons), simulated provider errors,
5//! and simulated latency — all without any network I/O. It is designed for
6//! tests that need to exercise streaming behavior, tool-use loops,
7//! retry-on-failure, and provider error classification deterministically.
8//!
9//! ## Why not the existing `MockProvider`?
10//!
11//! `mock::mock_llm_response` is a one-shot synchronous function: it returns
12//! a fully-formed `LlmResult` and emits a single delta containing the entire
13//! text. That is enough for non-streaming tests but loses the ability to
14//! observe per-token streaming order, mid-stream tool-call deltas, and
15//! provider-side latency.
16//!
17//! `FakeLlmProvider` keeps the deterministic, in-process spirit of the mock
18//! provider but adds:
19//!
20//! - **Real streaming.** Each [`FakeLlmEvent::Token`] sends an individual
21//!   delta, in script order, through the caller's `delta_tx`.
22//! - **Tool-call deltas.** Tool calls become both top-level
23//!   `tool_calls` entries and `tool_call` blocks in the result.
24//! - **Synthetic errors.** [`FakeLlmTurn::Error`] surfaces as
25//!   `VmError::CategorizedError`, byte-for-byte compatible with how a real
26//!   provider failure is classified, so retry/backoff paths exercise the
27//!   same code under test as in production.
28//! - **Simulated latency.** [`FakeLlmTurn::Stalled`] / [`FakeLlmEvent::Stall`]
29//!   call `tokio::time::sleep`, which is virtual under
30//!   `tokio::time::pause` — tests advance the clock with
31//!   `tokio::time::advance(...)` instead of waiting wall-clock time.
32//!
33//! ## Usage
34//!
35//! ```rust,ignore
36//! # use harn_vm::llm::{FakeLlmEvent, FakeLlmScript, FakeLlmTurn, FakeStopReason, install_fake_llm_script};
37//! let script = FakeLlmScript::new()
38//!     .push(FakeLlmTurn::stream(vec![
39//!         FakeLlmEvent::Token("hello ".into()),
40//!         FakeLlmEvent::Token("world".into()),
41//!         FakeLlmEvent::Done(FakeStopReason::EndTurn),
42//!     ]));
43//! let _guard = install_fake_llm_script(script);
44//! // run llm_call with provider: "fake" — assertions follow.
45//! // _guard, on drop, asserts every scripted turn was consumed.
46//! ```
47
48use std::cell::RefCell;
49use std::time::Duration;
50
51use crate::llm::api::{DeltaSender, LlmRequestPayload, LlmResult, ProviderTelemetry};
52use crate::llm::provider::{LlmProvider, LlmProviderChat};
53use crate::value::{ErrorCategory, VmError};
54
55/// One step of a fake LLM script.
56#[derive(Clone, Debug)]
57pub enum FakeLlmTurn {
58    /// Stream a sequence of events to the caller. Events fire in order;
59    /// `Done` (or end of vec) finalizes the turn into an `LlmResult`.
60    Stream(Vec<FakeLlmEvent>),
61    /// Surface a synthetic provider error. Mirrors the way real providers
62    /// fail so retry/classification logic exercises the same path.
63    Error(FakeLlmError),
64    /// Pause for `duration` before producing the next turn. Honors
65    /// `tokio::time::pause` — tests advance virtual time with
66    /// `tokio::time::advance(duration)`.
67    Stalled(Duration),
68}
69
70impl FakeLlmTurn {
71    /// Convenience constructor for a streaming turn.
72    pub fn stream(events: impl IntoIterator<Item = FakeLlmEvent>) -> Self {
73        Self::Stream(events.into_iter().collect())
74    }
75
76    /// Convenience constructor for an error turn.
77    pub fn error(category: ErrorCategory, message: impl Into<String>) -> Self {
78        Self::Error(FakeLlmError {
79            category,
80            message: message.into(),
81            retry_after_ms: None,
82        })
83    }
84}
85
86/// One event within a streaming turn.
87#[derive(Clone, Debug)]
88pub enum FakeLlmEvent {
89    /// Append `text` to the assistant's running text and emit it as a
90    /// streaming delta.
91    Token(String),
92    /// Add a tool call to the result. The fake provider produces both a
93    /// top-level `tool_calls` entry and a matching `tool_call` block.
94    ToolCallDelta {
95        id: String,
96        name: String,
97        arguments: serde_json::Value,
98    },
99    /// Pause mid-stream. Like `FakeLlmTurn::Stalled`, this honors
100    /// `tokio::time::pause`.
101    Stall(Duration),
102    /// Fail after any earlier deltas in this stream have already been sent.
103    /// Models a body read/reset failure after the provider committed output.
104    Error(FakeLlmError),
105    /// Finalize the turn with the given stop reason. Optional — when
106    /// omitted, the turn ends with `EndTurn` after the last event.
107    Done(FakeStopReason),
108}
109
110/// Stop reason for a streaming turn.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub enum FakeStopReason {
113    EndTurn,
114    ToolUse,
115    MaxTokens,
116    StopSequence,
117    Custom(String),
118}
119
120impl FakeStopReason {
121    fn as_str(&self) -> &str {
122        match self {
123            Self::EndTurn => "end_turn",
124            Self::ToolUse => "tool_use",
125            Self::MaxTokens => "max_tokens",
126            Self::StopSequence => "stop_sequence",
127            Self::Custom(value) => value.as_str(),
128        }
129    }
130}
131
132/// Synthetic provider failure surfaced by [`FakeLlmTurn::Error`].
133#[derive(Clone, Debug)]
134pub struct FakeLlmError {
135    pub category: ErrorCategory,
136    pub message: String,
137    /// Optional `retry-after` hint embedded in the error message so that
138    /// `agent_observe::extract_retry_after_ms` — the same parser used for
139    /// real HTTP 429s — surfaces the value on the caller's thrown dict.
140    pub retry_after_ms: Option<u64>,
141}
142
143impl FakeLlmError {
144    pub fn new(category: ErrorCategory, message: impl Into<String>) -> Self {
145        Self {
146            category,
147            message: message.into(),
148            retry_after_ms: None,
149        }
150    }
151
152    pub fn with_retry_after_ms(mut self, ms: u64) -> Self {
153        self.retry_after_ms = Some(ms);
154        self
155    }
156}
157
158/// A scripted sequence of fake LLM turns.
159///
160/// Turns are consumed FIFO: the first turn matches the first call, the
161/// second turn matches the second call, etc. The drop guard returned by
162/// [`install_fake_llm_script`] asserts on drop that every turn was
163/// consumed, surfacing accidentally-mismatched test expectations.
164#[derive(Clone, Debug, Default)]
165pub struct FakeLlmScript {
166    pub turns: Vec<FakeLlmTurn>,
167}
168
169impl FakeLlmScript {
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Build a single-turn script that streams the given events.
175    pub fn streaming(events: impl IntoIterator<Item = FakeLlmEvent>) -> Self {
176        Self {
177            turns: vec![FakeLlmTurn::stream(events)],
178        }
179    }
180
181    /// Build a single-turn script that surfaces an error.
182    pub fn erroring(category: ErrorCategory, message: impl Into<String>) -> Self {
183        Self {
184            turns: vec![FakeLlmTurn::error(category, message)],
185        }
186    }
187
188    /// Append a turn to the script.
189    pub fn push(mut self, turn: FakeLlmTurn) -> Self {
190        self.turns.push(turn);
191        self
192    }
193}
194
195/// One captured request from a fake LLM call. Tests use these to assert
196/// the provider was invoked with the expected prompt / tools / model.
197#[derive(Clone, Debug)]
198pub struct FakeLlmCall {
199    pub provider: String,
200    pub model: String,
201    pub system: Option<String>,
202    pub messages: Vec<serde_json::Value>,
203    pub native_tools: Option<Vec<serde_json::Value>>,
204    pub stream: bool,
205}
206
207impl FakeLlmCall {
208    fn from_request(request: &LlmRequestPayload) -> Self {
209        Self {
210            provider: request.provider.clone(),
211            model: request.model.clone(),
212            system: request.system.clone(),
213            messages: request.messages.clone(),
214            native_tools: request.native_tools.clone(),
215            stream: request.stream,
216        }
217    }
218}
219
220thread_local! {
221    static FAKE_LLM_TURNS: RefCell<Vec<FakeLlmTurn>> = const { RefCell::new(Vec::new()) };
222    static FAKE_LLM_CALLS: RefCell<Vec<FakeLlmCall>> = const { RefCell::new(Vec::new()) };
223}
224
225/// Install a script as the active fake-LLM playback queue and return a
226/// drop guard. The guard asserts on drop that every turn was consumed
227/// and resets the thread-local state.
228///
229/// Panics on drop (not during the test body) if turns remain or no script
230/// was active to begin with — both signal a mismatch between the test's
231/// expectations and what the code under test actually did.
232#[must_use = "FakeLlmGuard asserts on drop; bind it to a `_guard` local"]
233pub fn install_fake_llm_script(script: FakeLlmScript) -> FakeLlmGuard {
234    FAKE_LLM_TURNS.with(|turns| {
235        let mut turns = turns.borrow_mut();
236        assert!(
237            turns.is_empty(),
238            "FakeLlmProvider: a script is already installed; drop the previous guard before installing a new one"
239        );
240        *turns = script.turns;
241    });
242    FAKE_LLM_CALLS.with(|calls| calls.borrow_mut().clear());
243    FakeLlmGuard { _priv: () }
244}
245
246/// Captured fake-LLM calls observed since the active guard was installed.
247pub fn fake_llm_captured_calls() -> Vec<FakeLlmCall> {
248    FAKE_LLM_CALLS.with(|calls| calls.borrow().clone())
249}
250
251/// Drop guard returned by [`install_fake_llm_script`].
252///
253/// Asserts on drop that the script was fully consumed, then resets the
254/// thread-local state. Drop runs even on panic, so a failed assertion in
255/// the test body still cleans up.
256#[must_use]
257pub struct FakeLlmGuard {
258    _priv: (),
259}
260
261impl Drop for FakeLlmGuard {
262    fn drop(&mut self) {
263        let remaining = FAKE_LLM_TURNS.with(|turns| std::mem::take(&mut *turns.borrow_mut()));
264        FAKE_LLM_CALLS.with(|calls| calls.borrow_mut().clear());
265        // If we are unwinding from a prior panic, don't double-panic —
266        // the original failure is more informative.
267        if std::thread::panicking() {
268            return;
269        }
270        assert!(
271            remaining.is_empty(),
272            "FakeLlmProvider script had {} unconsumed turn(s); did the code under test make fewer LLM calls than expected?",
273            remaining.len()
274        );
275    }
276}
277
278/// Take the next turn from the active script, or return an explanatory
279/// error if no script is installed / the script is exhausted.
280fn take_next_turn(request: &LlmRequestPayload) -> Result<FakeLlmTurn, VmError> {
281    FAKE_LLM_CALLS.with(|calls| {
282        calls.borrow_mut().push(FakeLlmCall::from_request(request));
283    });
284    FAKE_LLM_TURNS.with(|turns| {
285        let mut turns = turns.borrow_mut();
286        if turns.is_empty() {
287            Err(VmError::Runtime(
288                "FakeLlmProvider: no script installed (or script exhausted) — install_fake_llm_script() must precede harness.llm.call(provider: \"fake\")".to_string()
289            ))
290        } else {
291            Ok(turns.remove(0))
292        }
293    })
294}
295
296/// Build the synthetic `retry-after` hint that the agent layer parses out
297/// of error messages. Mirrors `mock::mock_error_to_vm_error`.
298fn fake_error_to_vm_error(err: &FakeLlmError) -> VmError {
299    let message = match err.retry_after_ms {
300        Some(ms) => {
301            let secs = (ms as f64 / 1000.0).max(0.0);
302            let sep = if err.message.is_empty() || err.message.ends_with('\n') {
303                ""
304            } else {
305                "\n"
306            };
307            format!("{}{sep}retry-after: {secs}\n", err.message)
308        }
309        None => err.message.clone(),
310    };
311    VmError::CategorizedError {
312        message,
313        category: err.category.clone(),
314    }
315}
316
317/// Zero-cost unit struct registered as the `"fake"` provider.
318pub(crate) struct FakeLlmProvider;
319
320impl LlmProvider for FakeLlmProvider {
321    fn name(&self) -> &'static str {
322        "fake"
323    }
324
325    fn requires_model(&self) -> bool {
326        false
327    }
328}
329
330impl LlmProviderChat for FakeLlmProvider {
331    fn chat<'a>(
332        &'a self,
333        request: &'a LlmRequestPayload,
334        delta_tx: Option<DeltaSender>,
335    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<LlmResult, VmError>> + 'a>> {
336        Box::pin(self.chat_impl(request, delta_tx))
337    }
338}
339
340impl FakeLlmProvider {
341    /// Whether the request should route through the fake provider.
342    pub(crate) fn should_intercept(provider: &str) -> bool {
343        provider == "fake"
344    }
345
346    pub(crate) async fn chat_impl(
347        &self,
348        request: &LlmRequestPayload,
349        delta_tx: Option<DeltaSender>,
350    ) -> Result<LlmResult, VmError> {
351        loop {
352            let turn = take_next_turn(request)?;
353            match turn {
354                FakeLlmTurn::Stalled(duration) => {
355                    if !duration.is_zero() {
356                        tokio::time::sleep(duration).await;
357                    }
358                    // Loop to consume the next turn. `Stalled` is a delay
359                    // marker, not a producer — it gates the *following*
360                    // turn rather than ending the call on its own.
361                }
362                FakeLlmTurn::Error(err) => {
363                    return Err(fake_error_to_vm_error(&err));
364                }
365                FakeLlmTurn::Stream(events) => {
366                    return play_stream(request, events, delta_tx).await;
367                }
368            }
369        }
370    }
371}
372
373async fn play_stream(
374    request: &LlmRequestPayload,
375    events: Vec<FakeLlmEvent>,
376    delta_tx: Option<DeltaSender>,
377) -> Result<LlmResult, VmError> {
378    let mut text = String::new();
379    let mut tool_calls: Vec<serde_json::Value> = Vec::new();
380    let mut blocks: Vec<serde_json::Value> = Vec::new();
381    let mut stop_reason: Option<FakeStopReason> = None;
382    let mut next_tool_index: usize = 1;
383    // Mirror the real-transport mid-stream abort so tests can exercise
384    // `schema_stream_abort` end-to-end without standing up a live SSE
385    // server.
386    let mut schema_watch = crate::llm::api::StreamSchemaWatch::from_payload(request);
387
388    for event in events {
389        match event {
390            FakeLlmEvent::Token(chunk) => {
391                if let Some(tx) = delta_tx.as_ref() {
392                    let _ = tx.send(chunk.clone());
393                }
394                text.push_str(&chunk);
395                if let Some(watch) = schema_watch.as_mut() {
396                    if let Some(abort) = watch.observe(&chunk) {
397                        return Err(abort.into_vm_error());
398                    }
399                }
400            }
401            FakeLlmEvent::ToolCallDelta {
402                id,
403                name,
404                arguments,
405            } => {
406                let id = if id.is_empty() {
407                    let auto = format!("fake_call_{next_tool_index}");
408                    next_tool_index += 1;
409                    auto
410                } else {
411                    id
412                };
413                tool_calls.push(serde_json::json!({
414                    "id": id,
415                    "type": "tool_call",
416                    "name": name,
417                    "arguments": arguments,
418                }));
419                blocks.push(serde_json::json!({
420                    "type": "tool_call",
421                    "id": id,
422                    "name": name,
423                    "arguments": arguments,
424                    "visibility": "internal",
425                }));
426            }
427            FakeLlmEvent::Stall(duration) => {
428                if !duration.is_zero() {
429                    tokio::time::sleep(duration).await;
430                }
431            }
432            FakeLlmEvent::Error(error) => return Err(fake_error_to_vm_error(&error)),
433            FakeLlmEvent::Done(reason) => {
434                stop_reason = Some(reason);
435                break;
436            }
437        }
438    }
439
440    if !text.is_empty() {
441        // Place the consolidated text block at the front so call sites that
442        // peek at `blocks[0]` see prose before any tool-call blocks.
443        let text_block = serde_json::json!({
444            "type": "output_text",
445            "text": text,
446            "visibility": "public",
447        });
448        blocks.insert(0, text_block);
449    }
450
451    let stop_reason = stop_reason.unwrap_or(if tool_calls.is_empty() {
452        FakeStopReason::EndTurn
453    } else {
454        FakeStopReason::ToolUse
455    });
456
457    Ok(LlmResult {
458        text_projection: None,
459        served_fast: false,
460        text,
461        raw_tool_calls: Vec::new(),
462        tool_calls,
463        input_tokens: count_input_tokens(&request.messages),
464        output_tokens: 0,
465        cache_read_tokens: 0,
466        cache_write_tokens: 0,
467        cache_supported: true,
468        model: request.model.clone(),
469        provider: "fake".to_string(),
470        thinking: None,
471        thinking_summary: None,
472        stop_reason: Some(stop_reason.as_str().to_string()),
473        blocks,
474        logprobs: Vec::new(),
475        telemetry: ProviderTelemetry::default(),
476    })
477}
478
479/// Cheap deterministic input-token estimate based on the rendered prompt
480/// length. Matches the spirit of `mock_llm_response`'s estimate without
481/// pulling in the mock's pattern-matcher infrastructure.
482fn count_input_tokens(messages: &[serde_json::Value]) -> i64 {
483    fn collect(value: &serde_json::Value, out: &mut String) {
484        match value {
485            serde_json::Value::String(text) => {
486                out.push_str(text);
487                out.push('\n');
488            }
489            serde_json::Value::Array(items) => {
490                for item in items {
491                    collect(item, out);
492                }
493            }
494            serde_json::Value::Object(map) => {
495                for value in map.values() {
496                    collect(value, out);
497                }
498            }
499            _ => {}
500        }
501    }
502    let mut buf = String::new();
503    for message in messages {
504        collect(message, &mut buf);
505    }
506    buf.len() as i64
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::llm::api::{LlmApiMode, ThinkingConfig};
513    use crate::llm::api::{LlmRequestPayload, OutputFormat};
514
515    fn fake_request() -> LlmRequestPayload {
516        LlmRequestPayload {
517            provider: "fake".to_string(),
518            model: "fake-model".to_string(),
519            region: None,
520            api_key: String::new(),
521            api_mode: LlmApiMode::ChatCompletions,
522            messages: vec![serde_json::json!({"role": "user", "content": "hello"})],
523            system: None,
524            max_tokens: 64,
525            temperature: None,
526            top_p: None,
527            top_k: None,
528            logprobs: false,
529            top_logprobs: None,
530            stop: None,
531            seed: None,
532            frequency_penalty: None,
533            presence_penalty: None,
534            fast: false,
535            output_format: OutputFormat::Text,
536            response_format: None,
537            json_schema: None,
538            output_schema: None,
539            schema_stream_abort: false,
540            thinking: ThinkingConfig::Disabled,
541            anthropic_beta_features: Vec::new(),
542            vision: false,
543            native_tools: None,
544            provider_tools: Vec::new(),
545            tool_choice: None,
546            cache: false,
547            prompt_cache_ttl: None,
548            timeout: None,
549            idle_timeout: None,
550            stream: true,
551            provider_overrides: None,
552            previous_response_id: None,
553            store: None,
554            background: None,
555            truncation: None,
556            compact: None,
557            include: None,
558            max_tool_calls: None,
559            prefill: None,
560            session_id: None,
561            reminder_lifecycle: Vec::new(),
562            cli_llm_mock_scope: None,
563            mock_scope: None,
564        }
565    }
566
567    fn current_thread_runtime() -> tokio::runtime::Runtime {
568        tokio::runtime::Builder::new_current_thread()
569            .enable_all()
570            .start_paused(false)
571            .build()
572            .expect("runtime")
573    }
574
575    #[test]
576    fn streaming_turn_emits_deltas_in_order() {
577        let runtime = current_thread_runtime();
578        let _guard = install_fake_llm_script(FakeLlmScript::streaming(vec![
579            FakeLlmEvent::Token("hello ".into()),
580            FakeLlmEvent::Token("world".into()),
581            FakeLlmEvent::Done(FakeStopReason::EndTurn),
582        ]));
583
584        runtime.block_on(async {
585            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
586            let result = FakeLlmProvider
587                .chat_impl(&fake_request(), Some(tx))
588                .await
589                .expect("fake call should succeed");
590
591            let mut deltas = Vec::new();
592            while let Ok(delta) = rx.try_recv() {
593                deltas.push(delta);
594            }
595
596            assert_eq!(deltas, vec!["hello ".to_string(), "world".to_string()]);
597            assert_eq!(result.text, "hello world");
598            assert_eq!(result.provider, "fake");
599            assert_eq!(result.stop_reason.as_deref(), Some("end_turn"));
600            assert_eq!(result.blocks.len(), 1);
601            assert_eq!(result.blocks[0]["type"].as_str(), Some("output_text"));
602            assert_eq!(result.blocks[0]["text"].as_str(), Some("hello world"));
603        });
604        assert_eq!(fake_llm_captured_calls().len(), 1);
605    }
606
607    #[test]
608    fn tool_call_deltas_become_tool_calls_and_blocks() {
609        let runtime = current_thread_runtime();
610        let _guard = install_fake_llm_script(FakeLlmScript::streaming(vec![
611            FakeLlmEvent::Token("calling tool".into()),
612            FakeLlmEvent::ToolCallDelta {
613                id: String::new(),
614                name: "search".into(),
615                arguments: serde_json::json!({"q": "harn"}),
616            },
617            FakeLlmEvent::Done(FakeStopReason::ToolUse),
618        ]));
619
620        runtime.block_on(async {
621            let result = FakeLlmProvider
622                .chat_impl(&fake_request(), None)
623                .await
624                .expect("fake call should succeed");
625
626            assert_eq!(result.tool_calls.len(), 1);
627            assert_eq!(result.tool_calls[0]["name"].as_str(), Some("search"));
628            assert_eq!(result.tool_calls[0]["id"].as_str(), Some("fake_call_1"));
629            assert_eq!(
630                result.tool_calls[0]["arguments"]["q"].as_str(),
631                Some("harn")
632            );
633            assert_eq!(result.stop_reason.as_deref(), Some("tool_use"));
634            // First block is the consolidated text, second is the tool call.
635            assert_eq!(result.blocks[0]["type"].as_str(), Some("output_text"));
636            assert_eq!(result.blocks[1]["type"].as_str(), Some("tool_call"));
637            assert_eq!(result.blocks[1]["name"].as_str(), Some("search"));
638        });
639    }
640
641    #[test]
642    fn error_turn_returns_categorized_error() {
643        let runtime = current_thread_runtime();
644        let _guard = install_fake_llm_script(FakeLlmScript::erroring(
645            ErrorCategory::RateLimit,
646            "throttled",
647        ));
648
649        runtime.block_on(async {
650            let err = FakeLlmProvider
651                .chat_impl(&fake_request(), None)
652                .await
653                .expect_err("fake error turn should fail");
654            match err {
655                VmError::CategorizedError { message, category } => {
656                    assert_eq!(category, ErrorCategory::RateLimit);
657                    assert!(
658                        message.contains("throttled"),
659                        "error message should pass through: {message}"
660                    );
661                }
662                other => panic!("expected CategorizedError, got {other:?}"),
663            }
664        });
665    }
666
667    #[test]
668    fn error_turn_embeds_retry_after_hint() {
669        let runtime = current_thread_runtime();
670        let _guard = install_fake_llm_script(FakeLlmScript::default().push(FakeLlmTurn::Error(
671            FakeLlmError::new(ErrorCategory::RateLimit, "throttled").with_retry_after_ms(2_500),
672        )));
673
674        runtime.block_on(async {
675            let err = FakeLlmProvider
676                .chat_impl(&fake_request(), None)
677                .await
678                .expect_err("fake error turn should fail");
679            let VmError::CategorizedError { message, .. } = err else {
680                panic!("expected CategorizedError");
681            };
682            assert!(
683                message.contains("retry-after: 2.5"),
684                "retry-after hint should be present in synthetic message: {message}"
685            );
686        });
687    }
688
689    #[test]
690    fn stalled_turn_advances_under_paused_clock() {
691        let runtime = tokio::runtime::Builder::new_current_thread()
692            .enable_all()
693            .start_paused(true)
694            .build()
695            .expect("paused runtime");
696        let _guard = install_fake_llm_script(
697            FakeLlmScript::default()
698                .push(FakeLlmTurn::Stalled(Duration::from_mins(1)))
699                .push(FakeLlmTurn::stream(vec![
700                    FakeLlmEvent::Token("done".into()),
701                    FakeLlmEvent::Done(FakeStopReason::EndTurn),
702                ])),
703        );
704
705        runtime.block_on(async {
706            let request = fake_request();
707            let chat = FakeLlmProvider.chat_impl(&request, None);
708            tokio::pin!(chat);
709
710            // The chat future is parked on `tokio::time::sleep(60s)`. With
711            // a paused clock and no advance, polling once should leave it
712            // pending — proving real wall-clock is not required.
713            let polled = futures::poll!(&mut chat);
714            assert!(
715                matches!(polled, std::task::Poll::Pending),
716                "fake provider should be parked on the stall"
717            );
718
719            tokio::time::advance(Duration::from_mins(1)).await;
720            let result = chat.await.expect("after advance, fake call resolves");
721            assert_eq!(result.text, "done");
722        });
723    }
724
725    #[test]
726    fn multiple_turns_consumed_in_fifo_order() {
727        let runtime = current_thread_runtime();
728        let _guard = install_fake_llm_script(
729            FakeLlmScript::default()
730                .push(FakeLlmTurn::stream(vec![
731                    FakeLlmEvent::Token("first".into()),
732                    FakeLlmEvent::Done(FakeStopReason::EndTurn),
733                ]))
734                .push(FakeLlmTurn::stream(vec![
735                    FakeLlmEvent::Token("second".into()),
736                    FakeLlmEvent::Done(FakeStopReason::EndTurn),
737                ])),
738        );
739
740        runtime.block_on(async {
741            let first = FakeLlmProvider
742                .chat_impl(&fake_request(), None)
743                .await
744                .expect("first call");
745            let second = FakeLlmProvider
746                .chat_impl(&fake_request(), None)
747                .await
748                .expect("second call");
749            assert_eq!(first.text, "first");
750            assert_eq!(second.text, "second");
751        });
752
753        let calls = fake_llm_captured_calls();
754        assert_eq!(calls.len(), 2);
755        assert!(calls.iter().all(|c| c.provider == "fake"));
756    }
757
758    #[test]
759    #[should_panic(expected = "no script installed")]
760    fn calling_without_script_panics_with_explanatory_error() {
761        let runtime = current_thread_runtime();
762        // No guard — exercise the bare error path.
763        runtime
764            .block_on(async {
765                FakeLlmProvider
766                    .chat_impl(&fake_request(), None)
767                    .await
768                    .map_err(|e| e.to_string())
769            })
770            .unwrap();
771    }
772
773    #[test]
774    #[should_panic(expected = "unconsumed turn")]
775    fn drop_guard_asserts_on_unused_turns() {
776        let guard =
777            install_fake_llm_script(FakeLlmScript::default().push(FakeLlmTurn::stream(vec![
778                FakeLlmEvent::Done(FakeStopReason::EndTurn),
779            ])));
780        // Without making the call, the guard should panic on drop.
781        drop(guard);
782    }
783}