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]
32 Fifo,
33 ByToolName,
41 Exact,
52}
53
54#[derive(Clone)]
60pub struct ReplayProvider {
61 queue: Arc<Mutex<VecDeque<RecordedExchange>>>,
62 model_name: String,
63 strategy: ReplayStrategy,
64 tools: Option<Vec<ToolDefinition>>,
66}
67
68impl ReplayProvider {
69 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 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 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 pub fn with_strategy(mut self, strategy: ReplayStrategy) -> Self {
116 self.strategy = strategy;
117 self
118 }
119
120 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 pub fn len(&self) -> usize {
136 self.queue.lock().unwrap_or_else(|e| e.into_inner()).len()
137 }
138
139 pub fn is_empty(&self) -> bool {
141 self.len() == 0
142 }
143}
144
145fn 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
161fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}