Skip to main content

lc_testkit/
replay.rs

1//! `ReplayProvider`: replays from the recording file in FIFO order, zero network.
2//!
3//! Replay does **not match messages** (the prompt an LLMChain renders varies per call); it only
4//! pops recordings in order. An exhausted queue returns [`TestkitError::ReplayExhausted`].
5
6use std::collections::VecDeque;
7use std::io::BufRead;
8use std::path::Path;
9use std::pin::Pin;
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use futures_util::Stream;
14use lc_core::language_models::{BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk};
15use lc_core::runnables::{Runnable, RunnableConfig};
16use lc_core::tools::ToolDefinition;
17use lc_schema::Message;
18
19use crate::error::TestkitError;
20use crate::recording::RecordedExchange;
21
22/// Replay strategy: decides which recording a request takes in concurrent/out-of-order scenarios.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum ReplayStrategy {
25    /// Concurrent FIFO: pops in recording order, atomic pop has no UB, but "which request gets
26    /// which response" is not deterministic.
27    ///
28    /// Suits serial/order-stable chains; parallel scenarios use **order-independent assertions**
29    /// (only assert that replay is fully drained and each response is structurally non-empty),
30    /// never "the Nth response is tool A's".
31    #[default]
32    Fifo,
33    /// Routes by tool name: tools carry a name when bound (`bind_tools`), replay picks the first
34    /// recording in the queue whose tool name matches (request-side `exchange.tools` or
35    /// response-side `tool_calls`).
36    ///
37    /// Covers parallel scenarios where "different tools return different responses and need exact
38    /// correspondence" — each parallel branch binds its own tool and gets its own correct response.
39    /// Weaker than full message-signature matching, but reproducible.
40    ByToolName,
41    /// Matches the request messages by **full signature**: the request `messages` must be
42    /// **element-wise exactly equal** to `exchange.messages` (`Message`'s `PartialEq` over all
43    /// fields, including `content` / `message_type` / `name` / `additional_kwargs` / `tool_calls`).
44    ///
45    /// Under parallel out-of-order traffic each request exactly gets its own response; a recording
46    /// with no match returns an explicit [`TestkitError::ReplayNoMatch`], **never a silent FIFO
47    /// fallback** (which would disguise "wrong response" as success). Suits deterministic replays
48    /// where "the same message sequence always reproduces the same response" — any field drift
49    /// between the recording and the request (e.g. a runtime-assigned `id` that differs each time)
50    /// counts as no-match, so fixtures must keep those fields stable.
51    Exact,
52}
53
54/// Zero-network `BaseChatModel` that replays from a recording file.
55///
56/// The queue is shared via `Arc<Mutex<VecDeque>>`; `bind_tools` returns a new instance
57/// carrying a tool set and sharing the same queue with the original (FIFO order stays
58/// consistent across branches).
59#[derive(Clone)]
60pub struct ReplayProvider {
61    queue: Arc<Mutex<VecDeque<RecordedExchange>>>,
62    model_name: String,
63    strategy: ReplayStrategy,
64    /// Tool definitions bound on this instance (the matching key for `ByToolName` routing).
65    tools: Option<Vec<ToolDefinition>>,
66}
67
68impl ReplayProvider {
69    /// Reads a JSONL recording file (missing file / bad line → `Err`).
70    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TestkitError> {
71        let file = std::fs::File::open(path)?;
72        let reader = std::io::BufReader::new(file);
73        let mut queue = VecDeque::new();
74        for line in reader.lines() {
75            let line = line?.trim().to_string();
76            if line.is_empty() {
77                continue;
78            }
79            let exchange: RecordedExchange = serde_json::from_str(&line).map_err(|e| {
80                TestkitError::Io(std::io::Error::new(
81                    std::io::ErrorKind::InvalidData,
82                    format!("invalid recording line: {e}"),
83                ))
84            })?;
85            queue.push_back(exchange);
86        }
87        Ok(Self {
88            queue: Arc::new(Mutex::new(queue)),
89            model_name: "replay".to_string(),
90            strategy: ReplayStrategy::Fifo,
91            tools: None,
92        })
93    }
94
95    /// In-memory construction (hand-written recordings = the equivalent of a MockProvider).
96    pub fn from_exchanges(exchanges: Vec<RecordedExchange>) -> Self {
97        Self {
98            queue: Arc::new(Mutex::new(exchanges.into())),
99            model_name: "replay".to_string(),
100            strategy: ReplayStrategy::Fifo,
101            tools: None,
102        }
103    }
104
105    /// A single fixed response: every request returns the same `response` (simplest mock).
106    pub fn single(response: LLMResult) -> Self {
107        Self::from_exchanges(vec![RecordedExchange {
108            messages: Vec::new(),
109            response,
110            tools: None,
111        }])
112    }
113
114    /// Sets the replay strategy (default FIFO; `ByToolName` routes by tool name).
115    pub fn with_strategy(mut self, strategy: ReplayStrategy) -> Self {
116        self.strategy = strategy;
117        self
118    }
119
120    /// Binds tools: returns a new instance carrying that tool set (sharing the same replay queue).
121    ///
122    /// Binding tools on the replay side only satisfies the agent's "tools-bound loop" precondition —
123    /// the recording already contains the request-side `tools` and response-side `tool_calls`, so the
124    /// replay logic needs no change. Under `ByToolName`, `tools` also serves as the routing key.
125    pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self {
126        Self {
127            queue: self.queue.clone(),
128            model_name: self.model_name.clone(),
129            strategy: self.strategy,
130            tools: Some(tools),
131        }
132    }
133
134    /// Number of remaining recordings.
135    pub fn len(&self) -> usize {
136        self.queue.lock().unwrap_or_else(|e| e.into_inner()).len()
137    }
138
139    /// Whether no recordings remain.
140    pub fn is_empty(&self) -> bool {
141        self.len() == 0
142    }
143}
144
145/// `ByToolName` matching: true when either the request-side bound tool name or the
146/// response-side requested tool-call name hits.
147fn exchange_matches(exchange: &RecordedExchange, tool_name: &str) -> bool {
148    if let Some(tools) = &exchange.tools {
149        if tools.iter().any(|t| t.function.name == tool_name) {
150            return true;
151        }
152    }
153    if let Some(calls) = &exchange.response.tool_calls {
154        if calls.iter().any(|c| c.name() == tool_name) {
155            return true;
156        }
157    }
158    false
159}
160
161/// `Exact` matching: the request message sequence and the recorded message sequence are
162/// **element-wise exactly equal**.
163///
164/// `Vec<Message>`'s `PartialEq` compares length + each element, equivalent to "the message
165/// count and every field of every message (including `id` / `name` / `additional_kwargs` /
166/// `tool_calls`) match".
167fn messages_match(request: &[Message], recorded: &[Message]) -> bool {
168    request == recorded
169}
170
171#[async_trait]
172impl Runnable<Vec<Message>, LLMResult> for ReplayProvider {
173    type Error = TestkitError;
174
175    async fn invoke(
176        &self,
177        input: Vec<Message>,
178        config: Option<RunnableConfig>,
179    ) -> Result<LLMResult, Self::Error> {
180        self.chat(input, config).await
181    }
182}
183
184impl BaseLanguageModel<Vec<Message>, LLMResult> for ReplayProvider {
185    fn model_name(&self) -> &str {
186        &self.model_name
187    }
188
189    fn get_num_tokens(&self, text: &str) -> usize {
190        // Estimate: about 4 characters per token.
191        text.chars().count() / 4 + 1
192    }
193
194    fn temperature(&self) -> Option<f32> {
195        None
196    }
197
198    fn max_tokens(&self) -> Option<usize> {
199        None
200    }
201
202    fn with_temperature(self, _temp: f32) -> Self {
203        self
204    }
205
206    fn with_max_tokens(self, _max: usize) -> Self {
207        self
208    }
209}
210
211#[async_trait]
212impl BaseChatModel for ReplayProvider {
213    async fn chat(
214        &self,
215        messages: Vec<Message>,
216        _config: Option<RunnableConfig>,
217    ) -> Result<LLMResult, Self::Error> {
218        let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
219        let exchange = match self.strategy {
220            ReplayStrategy::Fifo => queue.pop_front(),
221            ReplayStrategy::ByToolName => {
222                let want = self
223                    .tools
224                    .as_ref()
225                    .and_then(|tools| tools.first().map(|t| t.function.name.clone()));
226                match want {
227                    // Pick the matching recording from the queue by tool name; no bound tool → fall back to FIFO.
228                    Some(name) => queue
229                        .iter()
230                        .position(|ex| exchange_matches(ex, &name))
231                        .map(|i| queue.remove(i).expect("position 必有元素")),
232                    None => queue.pop_front(),
233                }
234            }
235            ReplayStrategy::Exact => {
236                // Match exactly by full message signature, allowing parallel out-of-order; no match →
237                // explicit error, never a silent FIFO fallback (otherwise "wrong response" would look like success).
238                match queue
239                    .iter()
240                    .position(|ex| messages_match(&messages, &ex.messages))
241                {
242                    Some(i) => Some(queue.remove(i).expect("position 必有元素")),
243                    None => {
244                        return Err(TestkitError::ReplayNoMatch { left: queue.len() });
245                    }
246                }
247            }
248        };
249        let Some(exchange) = exchange else {
250            return Err(TestkitError::ReplayExhausted {
251                requested: messages.len(),
252            });
253        };
254        Ok(exchange.response)
255    }
256
257    async fn stream_chat(
258        &self,
259        messages: Vec<Message>,
260        config: Option<RunnableConfig>,
261    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
262    {
263        let response = self.chat(messages, config).await?;
264        let stream = futures_util::stream::iter(vec![Ok(StreamChunk {
265            text: response.content,
266            token_usage: response.token_usage,
267            tool_calls: None,
268        })]);
269        Ok(Box::pin(stream))
270    }
271
272    fn bind_tools(
273        &self,
274        tools: Vec<ToolDefinition>,
275    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
276        // Delegate to the inherent `bind_tools`: share the queue, record the tool set, return a new instance.
277        Some(Box::new(self.bind_tools(tools)))
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use lc_core::language_models::TokenUsage;
285    use lc_core::tools::{ToolCall, ToolDefinition};
286
287    fn exchange(content: &str) -> RecordedExchange {
288        RecordedExchange {
289            messages: vec![Message::system("ping")],
290            response: LLMResult {
291                content: content.to_string(),
292                model: "replay".to_string(),
293                token_usage: Some(TokenUsage {
294                    prompt_tokens: 1,
295                    completion_tokens: 2,
296                    total_tokens: 3,
297                }),
298                ..Default::default()
299            },
300            tools: None,
301        }
302    }
303
304    /// A recording with a response-side tool-call name (simulates the model requesting a tool).
305    fn exchange_with_tool_call(tool_name: &str, content: &str) -> RecordedExchange {
306        let mut response = exchange(content).response;
307        response.tool_calls = Some(vec![ToolCall::builder("call_1")
308            .name(tool_name)
309            .arguments("{}".to_string())
310            .build()]);
311        RecordedExchange {
312            response,
313            ..exchange(content)
314        }
315    }
316
317    /// A recording with a request-side bound tool name (simulates a request issued after binding a tool).
318    fn exchange_with_bound_tool(tool_name: &str, content: &str) -> RecordedExchange {
319        RecordedExchange {
320            tools: Some(vec![ToolDefinition::new(tool_name, "a tool")]),
321            ..exchange(content)
322        }
323    }
324
325    #[tokio::test]
326    async fn single_returns_fixed_response_for_any_request() {
327        let provider = ReplayProvider::single(exchange("hello").response);
328        let result = provider
329            .chat(vec![Message::system("any")], None)
330            .await
331            .unwrap();
332        assert_eq!(result.content, "hello");
333    }
334
335    #[tokio::test]
336    async fn replay_is_fifo_ordered() {
337        let provider = ReplayProvider::from_exchanges(vec![exchange("first"), exchange("second")]);
338        let first = provider
339            .chat(vec![Message::system("a")], None)
340            .await
341            .unwrap();
342        let second = provider
343            .chat(vec![Message::system("b")], None)
344            .await
345            .unwrap();
346        assert_eq!(first.content, "first");
347        assert_eq!(second.content, "second");
348    }
349
350    #[tokio::test]
351    async fn replay_exhausted_returns_error() {
352        let provider = ReplayProvider::from_exchanges(vec![exchange("only")]);
353        provider
354            .chat(vec![Message::system("a")], None)
355            .await
356            .unwrap();
357        let err = provider
358            .chat(vec![Message::system("b")], None)
359            .await
360            .unwrap_err();
361        assert!(matches!(
362            err,
363            TestkitError::ReplayExhausted { requested: 1 }
364        ));
365    }
366
367    #[test]
368    fn bind_tools_returns_some_and_carries_tools() {
369        let provider = ReplayProvider::from_exchanges(vec![exchange("x")]);
370        // The inherent bind_tools returns a new instance carrying the tool set (shared queue).
371        let bound = provider.bind_tools(vec![ToolDefinition::new("calculator", "calc")]);
372        assert!(bound.tools.is_some());
373        assert_eq!(bound.tools.as_ref().unwrap()[0].function.name, "calculator");
374        assert_eq!(provider.len(), 1);
375        assert_eq!(bound.len(), 1);
376        // The trait bind_tools (called by agents through `Box<dyn BaseChatModel>`) is always Some.
377        let trait_bound = BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("x", "y")]);
378        assert!(trait_bound.is_some());
379    }
380
381    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
382    async fn parallel_replay_fifo_is_order_independent() {
383        // Concurrent FIFO: two requests arrive simultaneously; which gets which response is
384        // nondeterministic, but both succeed and the queue drains. Assert with "set equality"
385        // rather than "the Nth is xx".
386        let provider = ReplayProvider::from_exchanges(vec![
387            exchange("first"),
388            exchange("second"),
389            exchange("third"),
390        ]);
391        let provider = std::sync::Arc::new(provider);
392
393        let mut handles = Vec::new();
394        for _ in 0..3 {
395            let p = provider.clone();
396            handles.push(tokio::spawn(async move {
397                p.chat(vec![Message::system("parallel")], None)
398                    .await
399                    .expect("并发回放不应失败")
400            }));
401        }
402        let mut contents: Vec<String> = Vec::new();
403        for handle in handles {
404            contents.push(handle.await.unwrap().content);
405        }
406        contents.sort();
407        assert_eq!(
408            contents,
409            vec![
410                "first".to_string(),
411                "second".to_string(),
412                "third".to_string()
413            ]
414        );
415        assert!(provider.is_empty(), "并发回放应恰好耗尽全部录播");
416    }
417
418    #[tokio::test]
419    async fn by_tool_name_routes_to_matching_exchange() {
420        // Each parallel branch binds its own tool and should get its own correct response — order-independent.
421        let provider = ReplayProvider::from_exchanges(vec![
422            exchange_with_tool_call("search", "search result"),
423            exchange_with_tool_call("calc", "calc result"),
424        ])
425        .with_strategy(ReplayStrategy::ByToolName);
426
427        let search =
428            BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("search", "s")]).unwrap();
429        let calc =
430            BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("calc", "c")]).unwrap();
431
432        let calc_res = calc.chat(vec![Message::system("q")], None).await.unwrap();
433        let search_res = search.chat(vec![Message::system("q")], None).await.unwrap();
434
435        assert_eq!(search_res.content, "search result");
436        assert_eq!(calc_res.content, "calc result");
437        assert!(provider.is_empty());
438    }
439
440    #[tokio::test]
441    async fn by_tool_name_matches_request_side_tools() {
442        // The request-side `exchange.tools` also participates in matching (recorded from requests issued after binding tools).
443        let provider = ReplayProvider::from_exchanges(vec![
444            exchange_with_bound_tool("weather", "sunny"),
445            exchange_with_bound_tool("news", "headlines"),
446        ])
447        .with_strategy(ReplayStrategy::ByToolName);
448
449        let weather =
450            BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("weather", "w")])
451                .unwrap();
452        let res = weather
453            .chat(vec![Message::system("q")], None)
454            .await
455            .unwrap();
456        assert_eq!(res.content, "sunny");
457    }
458
459    /// A recording with a specific request message sequence (for `Exact` strategy full-signature matching).
460    fn exchange_with_messages(messages: Vec<Message>, content: &str) -> RecordedExchange {
461        RecordedExchange {
462            messages,
463            ..exchange(content)
464        }
465    }
466
467    #[tokio::test]
468    async fn exact_strategy_matches_by_full_signature_out_of_order() {
469        // The two request message sequences differ; arriving out of order, each exactly gets its own response.
470        let provider = ReplayProvider::from_exchanges(vec![
471            exchange_with_messages(vec![Message::system("ping")], "pong"),
472            exchange_with_messages(vec![Message::human("hello")], "hi"),
473        ])
474        .with_strategy(ReplayStrategy::Exact);
475
476        let hello_res = provider
477            .chat(vec![Message::human("hello")], None)
478            .await
479            .unwrap();
480        let ping_res = provider
481            .chat(vec![Message::system("ping")], None)
482            .await
483            .unwrap();
484
485        assert_eq!(hello_res.content, "hi");
486        assert_eq!(ping_res.content, "pong");
487        assert!(provider.is_empty(), "两条请求应精确消耗两条录播");
488    }
489
490    #[tokio::test]
491    async fn exact_strategy_matches_full_message_sequence() {
492        // Multi-turn history (system + user + AI) acts as one whole signature, matched by the full sequence.
493        let msgs = vec![
494            Message::system("You are a calculator."),
495            Message::human("2 + 2"),
496            Message::ai("I'll compute that."),
497        ];
498        let provider = ReplayProvider::from_exchanges(vec![
499            exchange_with_messages(vec![Message::human("other")], "wrong"),
500            exchange_with_messages(msgs.clone(), "42"),
501        ])
502        .with_strategy(ReplayStrategy::Exact);
503
504        let res = provider.chat(msgs, None).await.unwrap();
505        assert_eq!(res.content, "42");
506    }
507
508    #[tokio::test]
509    async fn exact_strategy_no_match_returns_explicit_error() {
510        // No recording matches the request signature → explicit error (not a silent FIFO mispick).
511        let provider = ReplayProvider::from_exchanges(vec![exchange_with_messages(
512            vec![Message::system("ping")],
513            "pong",
514        )])
515        .with_strategy(ReplayStrategy::Exact);
516
517        let err = provider
518            .chat(vec![Message::system("different")], None)
519            .await
520            .unwrap_err();
521        assert!(
522            matches!(err, TestkitError::ReplayNoMatch { left: 1 }),
523            "无匹配应返回 ReplayNoMatch,剩余录播保留"
524        );
525    }
526
527    #[tokio::test]
528    async fn exact_strategy_distinguishes_message_type() {
529        // Same text, different message type = different signature.
530        let provider = ReplayProvider::from_exchanges(vec![
531            exchange_with_messages(vec![Message::human("q")], "human response"),
532            exchange_with_messages(vec![Message::system("q")], "system response"),
533        ])
534        .with_strategy(ReplayStrategy::Exact);
535
536        let human = provider
537            .chat(vec![Message::human("q")], None)
538            .await
539            .unwrap();
540        assert_eq!(human.content, "human response");
541
542        let system = provider
543            .chat(vec![Message::system("q")], None)
544            .await
545            .unwrap();
546        assert_eq!(system.content, "system response");
547    }
548
549    #[tokio::test]
550    async fn exact_strategy_distinguishes_tool_calls() {
551        // A message carrying tool calls and a plain-text message = different signatures.
552        let call = ToolCall::builder("call_1")
553            .name("weather")
554            .arguments("{}".to_string())
555            .build();
556        let provider = ReplayProvider::from_exchanges(vec![
557            exchange_with_messages(vec![Message::ai("q")], "plain"),
558            exchange_with_messages(
559                vec![Message::ai_with_tool_calls("q", vec![call.clone()])],
560                "with tool",
561            ),
562        ])
563        .with_strategy(ReplayStrategy::Exact);
564
565        let plain = provider.chat(vec![Message::ai("q")], None).await.unwrap();
566        assert_eq!(plain.content, "plain");
567
568        let with_tool = provider
569            .chat(vec![Message::ai_with_tool_calls("q", vec![call])], None)
570            .await
571            .unwrap();
572        assert_eq!(with_tool.content, "with tool");
573    }
574}