1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum ReplayStrategy {
25 #[default]
30 Fifo,
31 ByToolName,
37 Exact,
47}
48
49#[derive(Clone)]
54pub struct ReplayProvider {
55 queue: Arc<Mutex<VecDeque<RecordedExchange>>>,
56 model_name: String,
57 strategy: ReplayStrategy,
58 tools: Option<Vec<ToolDefinition>>,
60}
61
62impl ReplayProvider {
63 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TestkitError> {
65 let file = std::fs::File::open(path)?;
66 let reader = std::io::BufReader::new(file);
67 let mut queue = VecDeque::new();
68 for line in reader.lines() {
69 let line = line?.trim().to_string();
70 if line.is_empty() {
71 continue;
72 }
73 let exchange: RecordedExchange = serde_json::from_str(&line).map_err(|e| {
74 TestkitError::Io(std::io::Error::new(
75 std::io::ErrorKind::InvalidData,
76 format!("invalid recording line: {e}"),
77 ))
78 })?;
79 queue.push_back(exchange);
80 }
81 Ok(Self {
82 queue: Arc::new(Mutex::new(queue)),
83 model_name: "replay".to_string(),
84 strategy: ReplayStrategy::Fifo,
85 tools: None,
86 })
87 }
88
89 pub fn from_exchanges(exchanges: Vec<RecordedExchange>) -> Self {
91 Self {
92 queue: Arc::new(Mutex::new(exchanges.into())),
93 model_name: "replay".to_string(),
94 strategy: ReplayStrategy::Fifo,
95 tools: None,
96 }
97 }
98
99 pub fn single(response: LLMResult) -> Self {
101 Self::from_exchanges(vec![RecordedExchange {
102 messages: Vec::new(),
103 response,
104 tools: None,
105 }])
106 }
107
108 pub fn with_strategy(mut self, strategy: ReplayStrategy) -> Self {
110 self.strategy = strategy;
111 self
112 }
113
114 pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self {
120 Self {
121 queue: self.queue.clone(),
122 model_name: self.model_name.clone(),
123 strategy: self.strategy,
124 tools: Some(tools),
125 }
126 }
127
128 pub fn len(&self) -> usize {
130 self.queue.lock().unwrap_or_else(|e| e.into_inner()).len()
131 }
132
133 pub fn is_empty(&self) -> bool {
135 self.len() == 0
136 }
137}
138
139fn exchange_matches(exchange: &RecordedExchange, tool_name: &str) -> bool {
141 if let Some(tools) = &exchange.tools {
142 if tools.iter().any(|t| t.function.name == tool_name) {
143 return true;
144 }
145 }
146 if let Some(calls) = &exchange.response.tool_calls {
147 if calls.iter().any(|c| c.name() == tool_name) {
148 return true;
149 }
150 }
151 false
152}
153
154fn messages_match(request: &[Message], recorded: &[Message]) -> bool {
159 request == recorded
160}
161
162#[async_trait]
163impl Runnable<Vec<Message>, LLMResult> for ReplayProvider {
164 type Error = TestkitError;
165
166 async fn invoke(
167 &self,
168 input: Vec<Message>,
169 config: Option<RunnableConfig>,
170 ) -> Result<LLMResult, Self::Error> {
171 self.chat(input, config).await
172 }
173}
174
175impl BaseLanguageModel<Vec<Message>, LLMResult> for ReplayProvider {
176 fn model_name(&self) -> &str {
177 &self.model_name
178 }
179
180 fn get_num_tokens(&self, text: &str) -> usize {
181 text.chars().count() / 4 + 1
183 }
184
185 fn temperature(&self) -> Option<f32> {
186 None
187 }
188
189 fn max_tokens(&self) -> Option<usize> {
190 None
191 }
192
193 fn with_temperature(self, _temp: f32) -> Self {
194 self
195 }
196
197 fn with_max_tokens(self, _max: usize) -> Self {
198 self
199 }
200}
201
202#[async_trait]
203impl BaseChatModel for ReplayProvider {
204 async fn chat(
205 &self,
206 messages: Vec<Message>,
207 _config: Option<RunnableConfig>,
208 ) -> Result<LLMResult, Self::Error> {
209 let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
210 let exchange = match self.strategy {
211 ReplayStrategy::Fifo => queue.pop_front(),
212 ReplayStrategy::ByToolName => {
213 let want = self
214 .tools
215 .as_ref()
216 .and_then(|tools| tools.first().map(|t| t.function.name.clone()));
217 match want {
218 Some(name) => queue
220 .iter()
221 .position(|ex| exchange_matches(ex, &name))
222 .map(|i| queue.remove(i).expect("position 必有元素")),
223 None => queue.pop_front(),
224 }
225 }
226 ReplayStrategy::Exact => {
227 match queue
230 .iter()
231 .position(|ex| messages_match(&messages, &ex.messages))
232 {
233 Some(i) => Some(queue.remove(i).expect("position 必有元素")),
234 None => {
235 return Err(TestkitError::ReplayNoMatch { left: queue.len() });
236 }
237 }
238 }
239 };
240 let Some(exchange) = exchange else {
241 return Err(TestkitError::ReplayExhausted {
242 requested: messages.len(),
243 });
244 };
245 Ok(exchange.response)
246 }
247
248 async fn stream_chat(
249 &self,
250 messages: Vec<Message>,
251 config: Option<RunnableConfig>,
252 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
253 {
254 let response = self.chat(messages, config).await?;
255 let stream = futures_util::stream::iter(vec![Ok(StreamChunk {
256 text: response.content,
257 token_usage: response.token_usage,
258 })]);
259 Ok(Box::pin(stream))
260 }
261
262 fn bind_tools(
263 &self,
264 tools: Vec<ToolDefinition>,
265 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
266 Some(Box::new(self.bind_tools(tools)))
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use lc_core::language_models::TokenUsage;
275 use lc_core::tools::{ToolCall, ToolDefinition};
276
277 fn exchange(content: &str) -> RecordedExchange {
278 RecordedExchange {
279 messages: vec![Message::system("ping")],
280 response: LLMResult {
281 content: content.to_string(),
282 model: "replay".to_string(),
283 token_usage: Some(TokenUsage {
284 prompt_tokens: 1,
285 completion_tokens: 2,
286 total_tokens: 3,
287 }),
288 ..Default::default()
289 },
290 tools: None,
291 }
292 }
293
294 fn exchange_with_tool_call(tool_name: &str, content: &str) -> RecordedExchange {
296 let mut response = exchange(content).response;
297 response.tool_calls = Some(vec![ToolCall::builder("call_1")
298 .name(tool_name)
299 .arguments("{}".to_string())
300 .build()]);
301 RecordedExchange {
302 response,
303 ..exchange(content)
304 }
305 }
306
307 fn exchange_with_bound_tool(tool_name: &str, content: &str) -> RecordedExchange {
309 RecordedExchange {
310 tools: Some(vec![ToolDefinition::new(tool_name, "a tool")]),
311 ..exchange(content)
312 }
313 }
314
315 #[tokio::test]
316 async fn single_returns_fixed_response_for_any_request() {
317 let provider = ReplayProvider::single(exchange("hello").response);
318 let result = provider
319 .chat(vec![Message::system("any")], None)
320 .await
321 .unwrap();
322 assert_eq!(result.content, "hello");
323 }
324
325 #[tokio::test]
326 async fn replay_is_fifo_ordered() {
327 let provider = ReplayProvider::from_exchanges(vec![exchange("first"), exchange("second")]);
328 let first = provider
329 .chat(vec![Message::system("a")], None)
330 .await
331 .unwrap();
332 let second = provider
333 .chat(vec![Message::system("b")], None)
334 .await
335 .unwrap();
336 assert_eq!(first.content, "first");
337 assert_eq!(second.content, "second");
338 }
339
340 #[tokio::test]
341 async fn replay_exhausted_returns_error() {
342 let provider = ReplayProvider::from_exchanges(vec![exchange("only")]);
343 provider
344 .chat(vec![Message::system("a")], None)
345 .await
346 .unwrap();
347 let err = provider
348 .chat(vec![Message::system("b")], None)
349 .await
350 .unwrap_err();
351 assert!(matches!(
352 err,
353 TestkitError::ReplayExhausted { requested: 1 }
354 ));
355 }
356
357 #[test]
358 fn bind_tools_returns_some_and_carries_tools() {
359 let provider = ReplayProvider::from_exchanges(vec![exchange("x")]);
360 let bound = provider.bind_tools(vec![ToolDefinition::new("calculator", "calc")]);
362 assert!(bound.tools.is_some());
363 assert_eq!(bound.tools.as_ref().unwrap()[0].function.name, "calculator");
364 assert_eq!(provider.len(), 1);
365 assert_eq!(bound.len(), 1);
366 let trait_bound = BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("x", "y")]);
368 assert!(trait_bound.is_some());
369 }
370
371 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
372 async fn parallel_replay_fifo_is_order_independent() {
373 let provider = ReplayProvider::from_exchanges(vec![
376 exchange("first"),
377 exchange("second"),
378 exchange("third"),
379 ]);
380 let provider = std::sync::Arc::new(provider);
381
382 let mut handles = Vec::new();
383 for _ in 0..3 {
384 let p = provider.clone();
385 handles.push(tokio::spawn(async move {
386 p.chat(vec![Message::system("parallel")], None)
387 .await
388 .expect("并发回放不应失败")
389 }));
390 }
391 let mut contents: Vec<String> = Vec::new();
392 for handle in handles {
393 contents.push(handle.await.unwrap().content);
394 }
395 contents.sort();
396 assert_eq!(
397 contents,
398 vec![
399 "first".to_string(),
400 "second".to_string(),
401 "third".to_string()
402 ]
403 );
404 assert!(provider.is_empty(), "并发回放应恰好耗尽全部录播");
405 }
406
407 #[tokio::test]
408 async fn by_tool_name_routes_to_matching_exchange() {
409 let provider = ReplayProvider::from_exchanges(vec![
411 exchange_with_tool_call("search", "search result"),
412 exchange_with_tool_call("calc", "calc result"),
413 ])
414 .with_strategy(ReplayStrategy::ByToolName);
415
416 let search =
417 BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("search", "s")]).unwrap();
418 let calc =
419 BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("calc", "c")]).unwrap();
420
421 let calc_res = calc.chat(vec![Message::system("q")], None).await.unwrap();
422 let search_res = search.chat(vec![Message::system("q")], None).await.unwrap();
423
424 assert_eq!(search_res.content, "search result");
425 assert_eq!(calc_res.content, "calc result");
426 assert!(provider.is_empty());
427 }
428
429 #[tokio::test]
430 async fn by_tool_name_matches_request_side_tools() {
431 let provider = ReplayProvider::from_exchanges(vec![
433 exchange_with_bound_tool("weather", "sunny"),
434 exchange_with_bound_tool("news", "headlines"),
435 ])
436 .with_strategy(ReplayStrategy::ByToolName);
437
438 let weather =
439 BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("weather", "w")])
440 .unwrap();
441 let res = weather
442 .chat(vec![Message::system("q")], None)
443 .await
444 .unwrap();
445 assert_eq!(res.content, "sunny");
446 }
447
448 fn exchange_with_messages(messages: Vec<Message>, content: &str) -> RecordedExchange {
450 RecordedExchange {
451 messages,
452 ..exchange(content)
453 }
454 }
455
456 #[tokio::test]
457 async fn exact_strategy_matches_by_full_signature_out_of_order() {
458 let provider = ReplayProvider::from_exchanges(vec![
460 exchange_with_messages(vec![Message::system("ping")], "pong"),
461 exchange_with_messages(vec![Message::human("hello")], "hi"),
462 ])
463 .with_strategy(ReplayStrategy::Exact);
464
465 let hello_res = provider
466 .chat(vec![Message::human("hello")], None)
467 .await
468 .unwrap();
469 let ping_res = provider
470 .chat(vec![Message::system("ping")], None)
471 .await
472 .unwrap();
473
474 assert_eq!(hello_res.content, "hi");
475 assert_eq!(ping_res.content, "pong");
476 assert!(provider.is_empty(), "两条请求应精确消耗两条录播");
477 }
478
479 #[tokio::test]
480 async fn exact_strategy_matches_full_message_sequence() {
481 let msgs = vec![
483 Message::system("You are a calculator."),
484 Message::human("2 + 2"),
485 Message::ai("I'll compute that."),
486 ];
487 let provider = ReplayProvider::from_exchanges(vec![
488 exchange_with_messages(vec![Message::human("other")], "wrong"),
489 exchange_with_messages(msgs.clone(), "42"),
490 ])
491 .with_strategy(ReplayStrategy::Exact);
492
493 let res = provider.chat(msgs, None).await.unwrap();
494 assert_eq!(res.content, "42");
495 }
496
497 #[tokio::test]
498 async fn exact_strategy_no_match_returns_explicit_error() {
499 let provider = ReplayProvider::from_exchanges(vec![exchange_with_messages(
501 vec![Message::system("ping")],
502 "pong",
503 )])
504 .with_strategy(ReplayStrategy::Exact);
505
506 let err = provider
507 .chat(vec![Message::system("different")], None)
508 .await
509 .unwrap_err();
510 assert!(
511 matches!(err, TestkitError::ReplayNoMatch { left: 1 }),
512 "无匹配应返回 ReplayNoMatch,剩余录播保留"
513 );
514 }
515
516 #[tokio::test]
517 async fn exact_strategy_distinguishes_message_type() {
518 let provider = ReplayProvider::from_exchanges(vec![
520 exchange_with_messages(vec![Message::human("q")], "human response"),
521 exchange_with_messages(vec![Message::system("q")], "system response"),
522 ])
523 .with_strategy(ReplayStrategy::Exact);
524
525 let human = provider
526 .chat(vec![Message::human("q")], None)
527 .await
528 .unwrap();
529 assert_eq!(human.content, "human response");
530
531 let system = provider
532 .chat(vec![Message::system("q")], None)
533 .await
534 .unwrap();
535 assert_eq!(system.content, "system response");
536 }
537
538 #[tokio::test]
539 async fn exact_strategy_distinguishes_tool_calls() {
540 let call = ToolCall::builder("call_1")
542 .name("weather")
543 .arguments("{}".to_string())
544 .build();
545 let provider = ReplayProvider::from_exchanges(vec![
546 exchange_with_messages(vec![Message::ai("q")], "plain"),
547 exchange_with_messages(
548 vec![Message::ai_with_tool_calls("q", vec![call.clone()])],
549 "with tool",
550 ),
551 ])
552 .with_strategy(ReplayStrategy::Exact);
553
554 let plain = provider.chat(vec![Message::ai("q")], None).await.unwrap();
555 assert_eq!(plain.content, "plain");
556
557 let with_tool = provider
558 .chat(vec![Message::ai_with_tool_calls("q", vec![call])], None)
559 .await
560 .unwrap();
561 assert_eq!(with_tool.content, "with tool");
562 }
563}