Skip to main content

lc_memory/
with_history.rs

1// lc-memory/src/with_history.rs
2//! LCEL 组合用的"带记忆的 LLM 封装"。
3//!
4//! 把"读记忆 → 拼用户输入 → 调 LLM → 写回记忆"这一组合封装成单个
5//! `Runnable<String, LLMResult>`,让"LLM + 记忆"可以直接进入 LCEL 管道
6//! (pipe 成链、batch、stream 等),不用在业务代码里手写记忆胶水。
7//!
8//! # 两种记忆来源
9//!
10//! - `new(llm, memory)` —— 注入一个具体记忆对象(单会话,原有行为不变)。
11//! - `with_session_history(llm, factory)` —— 注入 **session 回调**(对齐 Python
12//!   `RunnableWithMessageHistory(llm, get_session_history)`):每次调用从
13//!   `config.configurable["session_id"]` 取槽,同一 session 共享历史,不同
14//!   session 互不串扰;缺失 `session_id` 返回 `LcelError::Chain`。
15//!
16//! # 语义
17//!
18//! `invoke(user_input)` 依次执行:
19//! 1. 按模式选定记忆(Shared 直接取;Sessions 按 session_id 取/建槽);
20//! 2. 从记忆读取历史,转成消息(`memory_variables_to_messages`);
21//! 3. 把用户输入作为 Human 消息追加到末尾;
22//! 4. 交给 `llm.chat`(可选 `RunnableConfig` 透传);
23//! 5. 把「用户输入 / 模型回答」写回记忆;
24//! 6. 返回完整 `LLMResult`。
25//!
26//! LLM 错误通过 `L::Error: Into<LcelError>` 进入管道错误;记忆读写错误
27//! 收敛为 `LcelError::Chain`。
28//!
29//! # 泛型
30//!
31//! `L` 是任意实现 `BaseChatModel` 的模型(原生 Provider / `LLMClient` 均可),
32//! 只要其错误类型能转进 `LcelError`(`LLMClient` 天然满足;原生 Provider 见
33//! lc-providers 的 `From<...> for LcelError`)。记忆以 trait 对象持有,任意
34//! `BaseMemory`(Buffer / Window / Summary / SummaryBuffer 等)都可用。
35
36use crate::base::{memory_variables_to_messages, BaseMemory};
37use crate::buffer::ConversationBufferMemory;
38use async_trait::async_trait;
39use lc_core::language_models::{BaseChatModel, LLMResult};
40use lc_core::runnables::{LcelError, Runnable, RunnableConfig};
41use lc_schema::Message;
42use std::collections::{HashMap, VecDeque};
43use std::sync::Arc;
44use tokio::sync::Mutex;
45
46/// 记忆句柄:任意 `BaseMemory` 的共享可变引用。
47pub type SharedMemory = Arc<Mutex<Box<dyn BaseMemory>>>;
48
49/// Session 缓存默认上限:防异常/恶意 session_id 无限增长占满内存(M2a)。
50const DEFAULT_MAX_SESSIONS: usize = 100;
51
52/// 带记忆的 LLM 封装,作为单个 Runnable 参与 LCEL 组合。
53pub struct RunnableWithMessageHistory<L> {
54    llm: Arc<L>,
55    mode: HistoryMode,
56}
57
58/// 记忆来源:共享单槽(SHARED)或按 session 分槽(SESSIONS)。
59enum HistoryMode {
60    /// 构造时注入的单一记忆对象,所有调用共享。
61    Shared(SharedMemory),
62    /// 按 `configurable.session_id` 分槽:factory 建新槽,缓存复用已建槽。
63    Sessions {
64        factory: Arc<dyn Fn(&str) -> SharedMemory + Send + Sync>,
65        /// 槽缓存:同一 session 复用同一份记忆(其锁同时串行化同槽并发 invoke)。
66        cache: Mutex<SessionCache>,
67        /// 缓存上限:超过即淘汰最旧 session,防 session_id 无限增长的内存 DoS(M2a)。
68        max_sessions: usize,
69        /// 仅用于 `memory()` 访问器的占位记忆(Sessions 模式下实际槽不唯一)。
70        default: SharedMemory,
71    },
72}
73
74/// Session 槽缓存:`slots` 按 session_id 取槽,`order` 记录插入顺序供上限淘汰。
75struct SessionCache {
76    slots: HashMap<String, SharedMemory>,
77    order: VecDeque<String>,
78}
79
80impl<L> RunnableWithMessageHistory<L> {
81    /// 用 LLM + 单个记忆对象构造封装(所有调用共享这一份记忆)。
82    pub fn new(llm: L, memory: impl BaseMemory + 'static) -> Self {
83        Self {
84            llm: Arc::new(llm),
85            mode: HistoryMode::Shared(Arc::new(Mutex::new(Box::new(memory)))),
86        }
87    }
88
89    /// 用 LLM + session 回调构造封装(对齐 Python
90    /// `RunnableWithMessageHistory(llm, get_session_history)`)。
91    ///
92    /// `factory(session_id)` 为一个 session 槽建出记忆对象;每次调用按
93    /// `config.configurable["session_id"]` 选槽,同一 session 复用已建槽。
94    ///
95    /// # Example
96    ///
97    /// ```rust,ignore
98    /// let pipe = RunnableWithMessageHistory::with_session_history(llm, |session_id| {
99    ///     Arc::new(Mutex::new(Box::new(
100    ///         ConversationBufferMemory::new().with_return_messages(true),
101    ///     ) as Box<dyn BaseMemory>))
102    /// });
103    /// let cfg = RunnableConfig::new().with_configurable("session_id", json!("s1"));
104    /// pipe.invoke("我叫小明".into(), Some(cfg)).await?;
105    /// ```
106    pub fn with_session_history<F>(llm: L, factory: F) -> Self
107    where
108        F: Fn(&str) -> SharedMemory + Send + Sync + 'static,
109    {
110        Self {
111            llm: Arc::new(llm),
112            mode: HistoryMode::Sessions {
113                factory: Arc::new(factory),
114                cache: Mutex::new(SessionCache {
115                    slots: HashMap::new(),
116                    order: VecDeque::new(),
117                }),
118                max_sessions: DEFAULT_MAX_SESSIONS,
119                default: Arc::new(Mutex::new(Box::new(ConversationBufferMemory::new()))),
120            },
121        }
122    }
123
124    /// 设置 session 缓存上限(Sessions 模式)。超过上限后,新 session 会淘汰最旧
125    /// 的槽,防 session_id 无限增长的内存 DoS(M2a)。默认 `DEFAULT_MAX_SESSIONS`。
126    pub fn with_max_sessions(mut self, max: usize) -> Self {
127        if let HistoryMode::Sessions { max_sessions, .. } = &mut self.mode {
128            *max_sessions = max.max(1);
129        }
130        self
131    }
132
133    /// 暴露内部记忆句柄,便于读取已保存的历史(调试、展示、验证写回等)。
134    ///
135    /// Sessions 模式下槽不唯一,返回的是占位记忆(不会参与管道读写);
136    /// 要检查真实历史请从管道内部或业务侧记忆对象读取。
137    pub fn memory(&self) -> SharedMemory {
138        match &self.mode {
139            HistoryMode::Shared(m) => m.clone(),
140            HistoryMode::Sessions { default, .. } => default.clone(),
141        }
142    }
143
144    /// 按模式选定本次调用要用的记忆槽。
145    async fn select_memory(
146        &self,
147        config: &Option<RunnableConfig>,
148    ) -> Result<SharedMemory, LcelError> {
149        match &self.mode {
150            HistoryMode::Shared(m) => Ok(m.clone()),
151            HistoryMode::Sessions {
152                factory,
153                cache,
154                max_sessions,
155                ..
156            } => {
157                let session_id = config
158                    .as_ref()
159                    .and_then(|c| c.configurable_value("session_id"))
160                    .and_then(|v| v.as_str())
161                    .ok_or_else(|| {
162                        LcelError::Chain(
163                            "RunnableWithMessageHistory (session mode) is missing configurable.session_id"
164                                .to_string(),
165                        )
166                    })?;
167                let mut cache = cache.lock().await;
168                if let Some(memory) = cache.slots.get(session_id) {
169                    return Ok(memory.clone());
170                }
171                // M2a: 缓存有上限,先淘汰最旧的 session,再建新槽。
172                if cache.slots.len() >= *max_sessions {
173                    if let Some(oldest) = cache.order.pop_front() {
174                        cache.slots.remove(&oldest);
175                    }
176                }
177                let memory = factory(session_id);
178                cache.slots.insert(session_id.to_string(), memory.clone());
179                cache.order.push_back(session_id.to_string());
180                Ok(memory)
181            }
182        }
183    }
184}
185
186#[async_trait]
187impl<L> Runnable<String, LLMResult> for RunnableWithMessageHistory<L>
188where
189    L: BaseChatModel + 'static,
190    L::Error: Into<LcelError>,
191{
192    type Error = LcelError;
193
194    async fn invoke(
195        &self,
196        input: String,
197        config: Option<RunnableConfig>,
198    ) -> Result<LLMResult, LcelError> {
199        // 0. 选定记忆槽(Sessions 模式读 configurable.session_id)
200        let memory = self.select_memory(&config).await?;
201
202        // M2b: 整个「读记忆 → 调 LLM → 写回」持锁执行,串行化同一记忆槽的并发
203        // invoke。旧实现读后即放锁,两个并发 invoke 都读到旧历史、写回互相覆盖
204        // 丢历史。代价是同槽调用串行,但带记忆的对话本就应串行。
205        let mut memory = memory.lock().await;
206
207        // 1. 读记忆 → 转消息
208        let mut messages = {
209            let vars = memory
210                .load_memory_variables(&HashMap::new())
211                .await
212                .map_err(|e| LcelError::Chain(format!("load memory: {e}")))?;
213            memory_variables_to_messages(&vars)
214        };
215
216        // 2. 拼上用户输入
217        messages.push(Message::human(&input));
218
219        // 3. 调 LLM(持锁等待:同槽串行,避免并发丢历史)
220        let result = self.llm.chat(messages, config).await.map_err(Into::into)?;
221
222        // 4. 写回记忆:失败不丢弃模型答案(否则调用方拿 Err 重试会重复调 LLM),
223        //    记 warn 暴露记忆层降级
224        let inputs = HashMap::from([("input".to_string(), input)]);
225        let outputs = HashMap::from([("output".to_string(), result.content.clone())]);
226        if let Err(e) = memory.save_context(&inputs, &outputs).await {
227            log::warn!("memory save failed (model answer still returned): {e}");
228        }
229
230        Ok(result)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use futures_util::Stream;
238    use lc_core::language_models::{BaseChatModel, BaseLanguageModel};
239    use lc_core::runnables::RunnableConfig;
240    use lc_schema::MessageType;
241    use serde_json::json;
242    use std::pin::Pin;
243    use std::sync::Mutex as StdMutex;
244
245    /// 测试 LLM:记录每次收到的消息,并把最后一条用户消息包成回答。
246    struct TestChatModel {
247        seen: Arc<StdMutex<Vec<Vec<Message>>>>,
248    }
249
250    #[async_trait]
251    impl Runnable<Vec<Message>, LLMResult> for TestChatModel {
252        type Error = LcelError;
253
254        async fn invoke(
255            &self,
256            input: Vec<Message>,
257            _config: Option<RunnableConfig>,
258        ) -> Result<LLMResult, LcelError> {
259            self.seen.lock().unwrap().push(input.clone());
260            let last = input.last().map(|m| m.content.clone()).unwrap_or_default();
261            Ok(LLMResult {
262                content: format!("reply to: {last}"),
263                ..Default::default()
264            })
265        }
266    }
267
268    #[async_trait]
269    impl BaseLanguageModel<Vec<Message>, LLMResult> for TestChatModel {
270        fn model_name(&self) -> &str {
271            "test-llm"
272        }
273
274        fn get_num_tokens(&self, text: &str) -> usize {
275            text.len()
276        }
277
278        fn with_temperature(self, _temp: f32) -> Self
279        where
280            Self: Sized,
281        {
282            self
283        }
284
285        fn with_max_tokens(self, _max: usize) -> Self
286        where
287            Self: Sized,
288        {
289            self
290        }
291    }
292
293    #[async_trait]
294    impl BaseChatModel for TestChatModel {
295        async fn chat(
296            &self,
297            messages: Vec<Message>,
298            config: Option<RunnableConfig>,
299        ) -> Result<LLMResult, LcelError> {
300            self.invoke(messages, config).await
301        }
302
303        async fn stream_chat(
304            &self,
305            _messages: Vec<Message>,
306            _config: Option<RunnableConfig>,
307        ) -> Result<Pin<Box<dyn Stream<Item = Result<String, LcelError>> + Send>>, LcelError>
308        {
309            unimplemented!("stream_chat not needed for tests")
310        }
311    }
312
313    /// session 回调:每次建一个空的 Buffer 记忆(return_messages = true)。
314    fn session_factory(_session_id: &str) -> SharedMemory {
315        Arc::new(Mutex::new(
316            Box::new(ConversationBufferMemory::new().with_return_messages(true))
317                as Box<dyn BaseMemory>,
318        ))
319    }
320
321    /// 读记忆 → LLM → 写回:第二轮调用时,LLM 应看到第一轮的完整对话。
322    #[tokio::test]
323    async fn reads_memory_writes_back_round_trip() {
324        // Arrange
325        let seen = Arc::new(StdMutex::new(Vec::new()));
326        let llm = TestChatModel { seen: seen.clone() };
327        // return_messages = true:历史以消息数组返回,方便断言每轮消息构成
328        let memory = ConversationBufferMemory::new().with_return_messages(true);
329        let pipe = RunnableWithMessageHistory::new(llm, memory);
330
331        // Act 第一轮:无历史
332        let r1 = pipe.invoke("我叫什么名字".to_string(), None).await.unwrap();
333        assert_eq!(r1.content, "reply to: 我叫什么名字");
334
335        // Act 第二轮:历史应已写回
336        let r2 = pipe.invoke("再问一次".to_string(), None).await.unwrap();
337        assert_eq!(r2.content, "reply to: 再问一次");
338
339        // Assert
340        let calls = seen.lock().unwrap();
341        assert_eq!(calls.len(), 2, "model should be called twice");
342        // 第一轮:只有用户消息
343        assert_eq!(calls[0].len(), 1);
344        assert_eq!(calls[0][0].content, "我叫什么名字");
345        // 第二轮:user + ai + 新 user(记忆已写回)
346        assert_eq!(calls[1].len(), 3);
347        assert_eq!(calls[1][0].content, "我叫什么名字");
348        assert!(matches!(calls[1][0].message_type, MessageType::Human));
349        assert!(matches!(calls[1][1].message_type, MessageType::AI));
350        assert_eq!(calls[1][2].content, "再问一次");
351    }
352
353    /// 记忆写回持久化在封装内:构造新封装、复用同一记忆类型,历史仍在。
354    #[tokio::test]
355    async fn memory_accumulates_across_invocations() {
356        // Arrange
357        let seen = Arc::new(StdMutex::new(Vec::new()));
358        let llm = TestChatModel { seen: seen.clone() };
359        let memory = ConversationBufferMemory::new().with_return_messages(true);
360        let pipe = RunnableWithMessageHistory::new(llm, memory);
361
362        // Act 三轮连续调用
363        for turn in ["你好", "你在吗", "再见"] {
364            pipe.invoke(turn.to_string(), None).await.unwrap();
365        }
366
367        // Assert 第三轮应看到前两轮完整四段对话
368        let calls = seen.lock().unwrap();
369        assert_eq!(calls.len(), 3);
370        assert_eq!(calls[2].len(), 5); // 2 轮 * 2 段 + 当前用户消息
371        assert_eq!(calls[2][4].content, "再见");
372    }
373
374    /// session 模式:同一 session_id 两轮调用共享历史。
375    #[tokio::test]
376    async fn session_history_same_session_shares_memory() {
377        // Arrange
378        let seen = Arc::new(StdMutex::new(Vec::new()));
379        let llm = TestChatModel { seen: seen.clone() };
380        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
381        let cfg = RunnableConfig::new().with_configurable("session_id", json!("s1"));
382
383        // Act 同一 session 两轮
384        let r1 = pipe
385            .invoke("我叫什么名字".to_string(), Some(cfg.clone()))
386            .await
387            .unwrap();
388        assert_eq!(r1.content, "reply to: 我叫什么名字");
389        let r2 = pipe
390            .invoke("再问一次".to_string(), Some(cfg))
391            .await
392            .unwrap();
393        assert_eq!(r2.content, "reply to: 再问一次");
394
395        // Assert 第二轮看到第一轮完整对话(user + ai + user)
396        let calls = seen.lock().unwrap();
397        assert_eq!(calls.len(), 2);
398        assert_eq!(calls[1].len(), 3);
399        assert_eq!(calls[1][0].content, "我叫什么名字");
400        assert!(matches!(calls[1][1].message_type, MessageType::AI));
401    }
402
403    /// session 模式:不同 session_id 互不串扰。
404    #[tokio::test]
405    async fn session_history_different_sessions_isolated() {
406        // Arrange
407        let seen = Arc::new(StdMutex::new(Vec::new()));
408        let llm = TestChatModel { seen: seen.clone() };
409        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
410        let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
411        let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
412
413        // Act s1 两轮 + s2 一轮
414        pipe.invoke("我是 s1".to_string(), Some(cfg_s1.clone()))
415            .await
416            .unwrap();
417        pipe.invoke("还在 s1".to_string(), Some(cfg_s1))
418            .await
419            .unwrap();
420        pipe.invoke("我是 s2".to_string(), Some(cfg_s2))
421            .await
422            .unwrap();
423
424        // Assert s2 第一轮无历史(1 条),s1 第二轮有历史(3 条)
425        let calls = seen.lock().unwrap();
426        assert_eq!(calls.len(), 3);
427        assert_eq!(calls[1].len(), 3, "s1 second turn should see history");
428        assert_eq!(calls[2].len(), 1, "s2 first turn should have no history");
429    }
430
431    /// session 模式:缺失 configurable.session_id → LcelError::Chain。
432    #[tokio::test]
433    async fn session_history_missing_session_id_errors() {
434        // Arrange
435        let seen = Arc::new(StdMutex::new(Vec::new()));
436        let llm = TestChatModel { seen };
437        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
438
439        // Act 无 config / 无 session_id
440        let err = pipe.invoke("你好".to_string(), None).await.unwrap_err();
441        assert!(matches!(err, LcelError::Chain(_)));
442
443        let cfg_no_sid = RunnableConfig::new();
444        let err = pipe
445            .invoke("你好".to_string(), Some(cfg_no_sid))
446            .await
447            .unwrap_err();
448        assert!(matches!(err, LcelError::Chain(_)));
449    }
450
451    /// 可阻塞的测试 LLM:第一次 chat 通知 `entered` 并阻塞在 `release`,用于在
452    /// 并发测试中制造「已持锁、模型调用中」的确定性窗口(M2b)。
453    struct BlockingChatModel {
454        seen: Arc<StdMutex<Vec<Vec<Message>>>>,
455        entered: Arc<tokio::sync::Notify>,
456        release: Arc<tokio::sync::Notify>,
457        blocked: Arc<StdMutex<bool>>,
458    }
459
460    #[async_trait]
461    impl Runnable<Vec<Message>, LLMResult> for BlockingChatModel {
462        type Error = LcelError;
463
464        async fn invoke(
465            &self,
466            input: Vec<Message>,
467            _config: Option<RunnableConfig>,
468        ) -> Result<LLMResult, LcelError> {
469            self.seen.lock().unwrap().push(input.clone());
470            let should_block = {
471                let mut blocked = self.blocked.lock().unwrap();
472                if !*blocked {
473                    *blocked = true;
474                    true
475                } else {
476                    false
477                }
478            };
479            self.entered.notify_one();
480            if should_block {
481                self.release.notified().await;
482            }
483            let last = input.last().map(|m| m.content.clone()).unwrap_or_default();
484            Ok(LLMResult {
485                content: format!("reply to: {last}"),
486                ..Default::default()
487            })
488        }
489    }
490
491    #[async_trait]
492    impl BaseLanguageModel<Vec<Message>, LLMResult> for BlockingChatModel {
493        fn model_name(&self) -> &str {
494            "blocking-test-llm"
495        }
496
497        fn get_num_tokens(&self, text: &str) -> usize {
498            text.len()
499        }
500
501        fn with_temperature(self, _temp: f32) -> Self
502        where
503            Self: Sized,
504        {
505            self
506        }
507
508        fn with_max_tokens(self, _max: usize) -> Self
509        where
510            Self: Sized,
511        {
512            self
513        }
514    }
515
516    #[async_trait]
517    impl BaseChatModel for BlockingChatModel {
518        async fn chat(
519            &self,
520            messages: Vec<Message>,
521            config: Option<RunnableConfig>,
522        ) -> Result<LLMResult, LcelError> {
523            self.invoke(messages, config).await
524        }
525
526        async fn stream_chat(
527            &self,
528            _messages: Vec<Message>,
529            _config: Option<RunnableConfig>,
530        ) -> Result<Pin<Box<dyn Stream<Item = Result<String, LcelError>> + Send>>, LcelError>
531        {
532            unimplemented!("stream_chat not needed for tests")
533        }
534    }
535
536    /// session 缓存有上限:超过后淘汰最旧 session,防 session_id 无限增长的内存 DoS(M2a)。
537    #[tokio::test]
538    async fn session_cache_evicts_oldest_when_over_capacity() {
539        // Arrange
540        let seen = Arc::new(StdMutex::new(Vec::new()));
541        let llm = TestChatModel { seen: seen.clone() };
542        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory)
543            .with_max_sessions(2);
544
545        let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
546        let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
547        let cfg_s3 = RunnableConfig::new().with_configurable("session_id", json!("s3"));
548
549        // Act s1、s2 占满缓存;s3 触发淘汰最旧的 s1。
550        pipe.invoke("s1-turn1".to_string(), Some(cfg_s1.clone()))
551            .await
552            .unwrap();
553        pipe.invoke("s2-turn1".to_string(), Some(cfg_s2))
554            .await
555            .unwrap();
556        pipe.invoke("s3-turn1".to_string(), Some(cfg_s3))
557            .await
558            .unwrap();
559        // s1 已被淘汰 → 重入 s1 是全新会话。
560        pipe.invoke("s1-turn2".to_string(), Some(cfg_s1))
561            .await
562            .unwrap();
563
564        // Assert
565        let calls = seen.lock().unwrap();
566        assert_eq!(calls.len(), 4);
567        assert_eq!(
568            calls[3].len(),
569            1,
570            "M2a: re-entering s1 after eviction should be a fresh session (no history)"
571        );
572    }
573
574    /// 同一记忆槽的并发 invoke 必须串行化:读→LLM→写整段持锁,避免并发都读到
575    /// 旧历史、写回互相覆盖丢上下文(M2b)。
576    #[tokio::test]
577    async fn concurrent_same_session_invokes_do_not_lose_history() {
578        // Arrange
579        let seen = Arc::new(StdMutex::new(Vec::new()));
580        let entered = Arc::new(tokio::sync::Notify::new());
581        let release = Arc::new(tokio::sync::Notify::new());
582        let blocked = Arc::new(StdMutex::new(false));
583        let llm = BlockingChatModel {
584            seen: seen.clone(),
585            entered: entered.clone(),
586            release: release.clone(),
587            blocked,
588        };
589        let pipe = Arc::new(RunnableWithMessageHistory::new(
590            llm,
591            ConversationBufferMemory::new().with_return_messages(true),
592        ));
593
594        // Act 第一轮 invoke:进入模型调用后阻塞,期间必须持有记忆锁。
595        let p1 = pipe.clone();
596        let h1 = tokio::spawn(async move { p1.invoke("第一轮".to_string(), None).await });
597        entered.notified().await;
598
599        // 第二轮 invoke:若读→LLM→写没有整段持锁,此刻会读到空历史(丢上下文)。
600        let p2 = pipe.clone();
601        let h2 = tokio::spawn(async move { p2.invoke("第二轮".to_string(), None).await });
602
603        // 放行第一轮:写回后才释放锁,第二轮才能读到第一轮完整对话。
604        release.notify_one();
605        let r1 = h1.await.unwrap().unwrap();
606        let r2 = h2.await.unwrap().unwrap();
607        assert_eq!(r1.content, "reply to: 第一轮");
608        assert_eq!(r2.content, "reply to: 第二轮");
609
610        // Assert
611        let calls = seen.lock().unwrap();
612        assert_eq!(calls.len(), 2);
613        assert_eq!(
614            calls[1].len(),
615            3,
616            "M2b: second turn should see the full first-turn conversation (user+ai+user), not empty history"
617        );
618        assert_eq!(calls[1][0].content, "第一轮");
619        assert_eq!(calls[1][2].content, "第二轮");
620    }
621}