Skip to main content

lc_memory/
with_history.rs

1// lc-memory/src/with_history.rs
2//! LCEL-composable "LLM wrapper with memory".
3//!
4//! Packages the "read memory → compose user input → call LLM → write back memory"
5//! composition into a single `Runnable<String, LLMResult>`, so "LLM + memory" can
6//! go straight into an LCEL pipeline (pipe into chains, batch, stream, etc.) without
7//! hand-writing memory glue in business code.
8//!
9//! # Two memory sources
10//!
11//! - `new(llm, memory)` — injects a concrete memory object (single session; original behavior).
12//! - `with_session_history(llm, factory)` — injects a **session callback** (mirrors Python's
13//!   `RunnableWithMessageHistory(llm, get_session_history)`): each call picks a slot from
14//!   `config.configurable["session_id"]`; the same session shares history, different sessions
15//!   never cross; a missing `session_id` returns `LcelError::Chain`.
16//!
17//! # Semantics
18//!
19//! `invoke(user_input)` runs in order:
20//! 1. select the memory by mode (Shared takes it directly; Sessions picks/creates a slot by session_id);
21//! 2. read history from memory, convert to messages (`memory_variables_to_messages`);
22//! 3. append the user input as a Human message;
23//! 4. hand to `llm.chat` (optional `RunnableConfig` passthrough);
24//! 5. write "user input / model answer" back to memory;
25//! 6. return the full `LLMResult`.
26//!
27//! LLM errors enter the pipeline error via `L::Error: Into<LcelError>`; memory read/write
28//! errors converge to `LcelError::Chain`.
29//!
30//! # Generics
31//!
32//! `L` is any model implementing `BaseChatModel` (native Provider / `LLMClient` both work),
33//! as long as its error type can convert into `LcelError` (`LLMClient` satisfies it naturally;
34//! native Providers see `From<...> for LcelError` in lc-providers). Memory is held as a trait
35//! object; any `BaseMemory` (Buffer / Window / Summary / SummaryBuffer, etc.) works.
36
37use crate::base::{memory_variables_to_messages, BaseMemory};
38use crate::buffer::ConversationBufferMemory;
39use async_trait::async_trait;
40use lc_core::language_models::{BaseChatModel, LLMResult};
41use lc_core::runnables::{LcelError, Runnable, RunnableConfig};
42use lc_schema::Message;
43use std::collections::{HashMap, VecDeque};
44use std::sync::Arc;
45use tokio::sync::Mutex;
46
47/// Memory handle: a shared mutable reference to any `BaseMemory`.
48pub type SharedMemory = Arc<Mutex<Box<dyn BaseMemory>>>;
49
50/// Default cap for the session cache: prevents runaway/malicious session_id growth from filling memory (M2a).
51const DEFAULT_MAX_SESSIONS: usize = 100;
52
53/// LLM wrapper with memory, participating in LCEL composition as a single Runnable.
54pub struct RunnableWithMessageHistory<L> {
55    llm: Arc<L>,
56    mode: HistoryMode,
57}
58
59/// Memory source: a shared single slot (SHARED) or per-session slots (SESSIONS).
60enum HistoryMode {
61    /// A single memory object injected at construction; all calls share it.
62    Shared(SharedMemory),
63    /// Per-slot by `configurable.session_id`: the factory builds new slots, the cache reuses existing ones.
64    Sessions {
65        factory: Arc<dyn Fn(&str) -> SharedMemory + Send + Sync>,
66        /// Slot cache: the same session reuses the same memory (its lock also serializes concurrent invokes on the same slot).
67        cache: Mutex<SessionCache>,
68        /// Cache cap: evicts the oldest session when exceeded, preventing a memory DoS from unbounded session_id growth (M2a).
69        max_sessions: usize,
70        /// Placeholder memory used only by the `memory()` accessor (in Sessions mode the real slot is not unique).
71        default: SharedMemory,
72    },
73}
74
75/// Session slot cache: `slots` looks up by session_id, `order` records insertion order for cap-based eviction.
76struct SessionCache {
77    slots: HashMap<String, SharedMemory>,
78    order: VecDeque<String>,
79}
80
81impl<L> RunnableWithMessageHistory<L> {
82    /// Builds the wrapper from an LLM + a single memory object (all calls share this memory).
83    pub fn new(llm: L, memory: impl BaseMemory + 'static) -> Self {
84        Self {
85            llm: Arc::new(llm),
86            mode: HistoryMode::Shared(Arc::new(Mutex::new(Box::new(memory)))),
87        }
88    }
89
90    /// Builds the wrapper from an LLM + a session callback (mirrors Python's
91    /// `RunnableWithMessageHistory(llm, get_session_history)`).
92    ///
93    /// `factory(session_id)` builds a memory object for one session slot; each call selects the
94    /// slot by `config.configurable["session_id"]`, and the same session reuses its built slot.
95    ///
96    /// # Example
97    ///
98    /// ```rust,ignore
99    /// let pipe = RunnableWithMessageHistory::with_session_history(llm, |session_id| {
100    ///     Arc::new(Mutex::new(Box::new(
101    ///         ConversationBufferMemory::new().with_return_messages(true),
102    ///     ) as Box<dyn BaseMemory>))
103    /// });
104    /// let cfg = RunnableConfig::new().with_configurable("session_id", json!("s1"));
105    /// pipe.invoke("我叫小明".into(), Some(cfg)).await?;
106    /// ```
107    pub fn with_session_history<F>(llm: L, factory: F) -> Self
108    where
109        F: Fn(&str) -> SharedMemory + Send + Sync + 'static,
110    {
111        Self {
112            llm: Arc::new(llm),
113            mode: HistoryMode::Sessions {
114                factory: Arc::new(factory),
115                cache: Mutex::new(SessionCache {
116                    slots: HashMap::new(),
117                    order: VecDeque::new(),
118                }),
119                max_sessions: DEFAULT_MAX_SESSIONS,
120                default: Arc::new(Mutex::new(Box::new(ConversationBufferMemory::new()))),
121            },
122        }
123    }
124
125    /// Sets the session-cache cap (Sessions mode). When exceeded, a new session evicts the
126    /// oldest slot, preventing a memory DoS from unbounded session_id growth (M2a).
127    /// Defaults to `DEFAULT_MAX_SESSIONS`.
128    pub fn with_max_sessions(mut self, max: usize) -> Self {
129        if let HistoryMode::Sessions { max_sessions, .. } = &mut self.mode {
130            *max_sessions = max.max(1);
131        }
132        self
133    }
134
135    /// Exposes the internal memory handle for reading saved history (debugging, display,
136    /// verifying write-back, etc.).
137    ///
138    /// In Sessions mode the slot is not unique, so a placeholder memory is returned (it does
139    /// not participate in pipeline reads/writes); to inspect real history, read from the
140    /// pipeline internals or the business-side memory object.
141    pub fn memory(&self) -> SharedMemory {
142        match &self.mode {
143            HistoryMode::Shared(m) => m.clone(),
144            HistoryMode::Sessions { default, .. } => default.clone(),
145        }
146    }
147
148    /// Selects the memory slot this call will use, by mode.
149    async fn select_memory(
150        &self,
151        config: &Option<RunnableConfig>,
152    ) -> Result<SharedMemory, LcelError> {
153        match &self.mode {
154            HistoryMode::Shared(m) => Ok(m.clone()),
155            HistoryMode::Sessions {
156                factory,
157                cache,
158                max_sessions,
159                ..
160            } => {
161                let session_id = config
162                    .as_ref()
163                    .and_then(|c| c.configurable_value("session_id"))
164                    .and_then(|v| v.as_str())
165                    .ok_or_else(|| {
166                        LcelError::Chain(
167                            "RunnableWithMessageHistory (session mode) is missing configurable.session_id"
168                                .to_string(),
169                        )
170                    })?;
171                let mut cache = cache.lock().await;
172                if let Some(memory) = cache.slots.get(session_id) {
173                    return Ok(memory.clone());
174                }
175                // M2a: the cache has a cap — evict the oldest session first, then build the new slot.
176                if cache.slots.len() >= *max_sessions {
177                    if let Some(oldest) = cache.order.pop_front() {
178                        cache.slots.remove(&oldest);
179                    }
180                }
181                let memory = factory(session_id);
182                cache.slots.insert(session_id.to_string(), memory.clone());
183                cache.order.push_back(session_id.to_string());
184                Ok(memory)
185            }
186        }
187    }
188}
189
190#[async_trait]
191impl<L> Runnable<String, LLMResult> for RunnableWithMessageHistory<L>
192where
193    L: BaseChatModel + 'static,
194    L::Error: Into<LcelError>,
195{
196    type Error = LcelError;
197
198    async fn invoke(
199        &self,
200        input: String,
201        config: Option<RunnableConfig>,
202    ) -> Result<LLMResult, LcelError> {
203        // 0. select the memory slot (Sessions mode reads configurable.session_id)
204        let memory = self.select_memory(&config).await?;
205
206        // M2b: hold the lock across the whole "read memory → call LLM → write back", serializing
207        // concurrent invokes on the same memory slot. The old implementation released the lock
208        // right after reading, so two concurrent invokes both read stale history and overwrote
209        // each other's write-back, losing history. The cost is that same-slot calls serialize,
210        // but memory-backed conversation should be serial anyway.
211        let mut memory = memory.lock().await;
212
213        // 1. read memory → convert to messages
214        let mut messages = {
215            let vars = memory
216                .load_memory_variables(&HashMap::new())
217                .await
218                .map_err(|e| LcelError::Chain(format!("load memory: {e}")))?;
219            memory_variables_to_messages(&vars)
220        };
221
222        // 2. append the user input
223        messages.push(Message::human(&input));
224
225        // 3. call the LLM (holding the lock: same-slot calls serialize, avoiding concurrent history loss)
226        let result = self.llm.chat(messages, config).await.map_err(Into::into)?;
227
228        // 4. write back to memory: on failure the model answer is not discarded (otherwise the
229        //    caller retrying on Err would re-invoke the LLM); log a warn to expose the memory-layer degradation
230        let inputs = HashMap::from([("input".to_string(), input)]);
231        let outputs = HashMap::from([("output".to_string(), result.content.clone())]);
232        if let Err(e) = memory.save_context(&inputs, &outputs).await {
233            log::warn!("memory save failed (model answer still returned): {e}");
234        }
235
236        Ok(result)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use futures_util::Stream;
244    use lc_core::language_models::{BaseChatModel, BaseLanguageModel, StreamChunk};
245    use lc_core::runnables::RunnableConfig;
246    use lc_schema::MessageType;
247    use serde_json::json;
248    use std::pin::Pin;
249    use std::sync::Mutex as StdMutex;
250
251    /// Test LLM: records every received message and wraps the last user message as the reply.
252    struct TestChatModel {
253        seen: Arc<StdMutex<Vec<Vec<Message>>>>,
254    }
255
256    #[async_trait]
257    impl Runnable<Vec<Message>, LLMResult> for TestChatModel {
258        type Error = LcelError;
259
260        async fn invoke(
261            &self,
262            input: Vec<Message>,
263            _config: Option<RunnableConfig>,
264        ) -> Result<LLMResult, LcelError> {
265            self.seen.lock().unwrap().push(input.clone());
266            let last = input.last().map(|m| m.content.clone()).unwrap_or_default();
267            Ok(LLMResult {
268                content: format!("reply to: {last}"),
269                ..Default::default()
270            })
271        }
272    }
273
274    #[async_trait]
275    impl BaseLanguageModel<Vec<Message>, LLMResult> for TestChatModel {
276        fn model_name(&self) -> &str {
277            "test-llm"
278        }
279
280        fn get_num_tokens(&self, text: &str) -> usize {
281            text.len()
282        }
283
284        fn with_temperature(self, _temp: f32) -> Self
285        where
286            Self: Sized,
287        {
288            self
289        }
290
291        fn with_max_tokens(self, _max: usize) -> Self
292        where
293            Self: Sized,
294        {
295            self
296        }
297    }
298
299    #[async_trait]
300    impl BaseChatModel for TestChatModel {
301        async fn chat(
302            &self,
303            messages: Vec<Message>,
304            config: Option<RunnableConfig>,
305        ) -> Result<LLMResult, LcelError> {
306            self.invoke(messages, config).await
307        }
308
309        async fn stream_chat(
310            &self,
311            _messages: Vec<Message>,
312            _config: Option<RunnableConfig>,
313        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LcelError>> + Send>>, LcelError>
314        {
315            unimplemented!("stream_chat not needed for tests")
316        }
317    }
318
319    /// Session callback: builds an empty Buffer memory per session (return_messages = true).
320    fn session_factory(_session_id: &str) -> SharedMemory {
321        Arc::new(Mutex::new(
322            Box::new(ConversationBufferMemory::new().with_return_messages(true))
323                as Box<dyn BaseMemory>,
324        ))
325    }
326
327    /// Read memory → LLM → write back: on the second call, the LLM should see the first turn's full conversation.
328    #[tokio::test]
329    async fn reads_memory_writes_back_round_trip() {
330        // Arrange
331        let seen = Arc::new(StdMutex::new(Vec::new()));
332        let llm = TestChatModel { seen: seen.clone() };
333        // return_messages = true: history returns as a message array, making per-turn message composition easy to assert
334        let memory = ConversationBufferMemory::new().with_return_messages(true);
335        let pipe = RunnableWithMessageHistory::new(llm, memory);
336
337        // Act turn 1: no history
338        let r1 = pipe.invoke("我叫什么名字".to_string(), None).await.unwrap();
339        assert_eq!(r1.content, "reply to: 我叫什么名字");
340
341        // Act turn 2: history should have been written back
342        let r2 = pipe.invoke("再问一次".to_string(), None).await.unwrap();
343        assert_eq!(r2.content, "reply to: 再问一次");
344
345        // Assert
346        let calls = seen.lock().unwrap();
347        assert_eq!(calls.len(), 2, "model should be called twice");
348        // Turn 1: only the user message
349        assert_eq!(calls[0].len(), 1);
350        assert_eq!(calls[0][0].content, "我叫什么名字");
351        // Turn 2: user + ai + new user (memory written back)
352        assert_eq!(calls[1].len(), 3);
353        assert_eq!(calls[1][0].content, "我叫什么名字");
354        assert!(matches!(calls[1][0].message_type, MessageType::Human));
355        assert!(matches!(calls[1][1].message_type, MessageType::AI));
356        assert_eq!(calls[1][2].content, "再问一次");
357    }
358
359    /// Memory write-back persists inside the wrapper: a new wrapper reusing the same memory type still has the history.
360    #[tokio::test]
361    async fn memory_accumulates_across_invocations() {
362        // Arrange
363        let seen = Arc::new(StdMutex::new(Vec::new()));
364        let llm = TestChatModel { seen: seen.clone() };
365        let memory = ConversationBufferMemory::new().with_return_messages(true);
366        let pipe = RunnableWithMessageHistory::new(llm, memory);
367
368        // Act three consecutive turns
369        for turn in ["你好", "你在吗", "再见"] {
370            pipe.invoke(turn.to_string(), None).await.unwrap();
371        }
372
373        // Assert turn 3 should see the full four messages of turns 1-2
374        let calls = seen.lock().unwrap();
375        assert_eq!(calls.len(), 3);
376        assert_eq!(calls[2].len(), 5); // 2 turns * 2 messages + the current user message
377        assert_eq!(calls[2][4].content, "再见");
378    }
379
380    /// Session mode: two calls with the same session_id share history.
381    #[tokio::test]
382    async fn session_history_same_session_shares_memory() {
383        // Arrange
384        let seen = Arc::new(StdMutex::new(Vec::new()));
385        let llm = TestChatModel { seen: seen.clone() };
386        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
387        let cfg = RunnableConfig::new().with_configurable("session_id", json!("s1"));
388
389        // Act two turns on the same session
390        let r1 = pipe
391            .invoke("我叫什么名字".to_string(), Some(cfg.clone()))
392            .await
393            .unwrap();
394        assert_eq!(r1.content, "reply to: 我叫什么名字");
395        let r2 = pipe
396            .invoke("再问一次".to_string(), Some(cfg))
397            .await
398            .unwrap();
399        assert_eq!(r2.content, "reply to: 再问一次");
400
401        // Assert turn 2 sees turn 1's full conversation (user + ai + user)
402        let calls = seen.lock().unwrap();
403        assert_eq!(calls.len(), 2);
404        assert_eq!(calls[1].len(), 3);
405        assert_eq!(calls[1][0].content, "我叫什么名字");
406        assert!(matches!(calls[1][1].message_type, MessageType::AI));
407    }
408
409    /// Session mode: different session_ids never cross-contaminate.
410    #[tokio::test]
411    async fn session_history_different_sessions_isolated() {
412        // Arrange
413        let seen = Arc::new(StdMutex::new(Vec::new()));
414        let llm = TestChatModel { seen: seen.clone() };
415        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
416        let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
417        let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
418
419        // Act s1 two turns + s2 one turn
420        pipe.invoke("我是 s1".to_string(), Some(cfg_s1.clone()))
421            .await
422            .unwrap();
423        pipe.invoke("还在 s1".to_string(), Some(cfg_s1))
424            .await
425            .unwrap();
426        pipe.invoke("我是 s2".to_string(), Some(cfg_s2))
427            .await
428            .unwrap();
429
430        // Assert s2's first turn has no history (1 message); s1's second turn has history (3 messages)
431        let calls = seen.lock().unwrap();
432        assert_eq!(calls.len(), 3);
433        assert_eq!(calls[1].len(), 3, "s1 second turn should see history");
434        assert_eq!(calls[2].len(), 1, "s2 first turn should have no history");
435    }
436
437    /// Session mode: missing configurable.session_id → LcelError::Chain.
438    #[tokio::test]
439    async fn session_history_missing_session_id_errors() {
440        // Arrange
441        let seen = Arc::new(StdMutex::new(Vec::new()));
442        let llm = TestChatModel { seen };
443        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
444
445        // Act no config / no session_id
446        let err = pipe.invoke("你好".to_string(), None).await.unwrap_err();
447        assert!(matches!(err, LcelError::Chain(_)));
448
449        let cfg_no_sid = RunnableConfig::new();
450        let err = pipe
451            .invoke("你好".to_string(), Some(cfg_no_sid))
452            .await
453            .unwrap_err();
454        assert!(matches!(err, LcelError::Chain(_)));
455    }
456
457    /// Blockable test LLM: the first chat notifies `entered` and blocks on `release`, used to
458    /// create a deterministic "lock held, model in-flight" window in concurrency tests (M2b).
459    struct BlockingChatModel {
460        seen: Arc<StdMutex<Vec<Vec<Message>>>>,
461        entered: Arc<tokio::sync::Notify>,
462        release: Arc<tokio::sync::Notify>,
463        blocked: Arc<StdMutex<bool>>,
464    }
465
466    #[async_trait]
467    impl Runnable<Vec<Message>, LLMResult> for BlockingChatModel {
468        type Error = LcelError;
469
470        async fn invoke(
471            &self,
472            input: Vec<Message>,
473            _config: Option<RunnableConfig>,
474        ) -> Result<LLMResult, LcelError> {
475            self.seen.lock().unwrap().push(input.clone());
476            let should_block = {
477                let mut blocked = self.blocked.lock().unwrap();
478                if !*blocked {
479                    *blocked = true;
480                    true
481                } else {
482                    false
483                }
484            };
485            self.entered.notify_one();
486            if should_block {
487                self.release.notified().await;
488            }
489            let last = input.last().map(|m| m.content.clone()).unwrap_or_default();
490            Ok(LLMResult {
491                content: format!("reply to: {last}"),
492                ..Default::default()
493            })
494        }
495    }
496
497    #[async_trait]
498    impl BaseLanguageModel<Vec<Message>, LLMResult> for BlockingChatModel {
499        fn model_name(&self) -> &str {
500            "blocking-test-llm"
501        }
502
503        fn get_num_tokens(&self, text: &str) -> usize {
504            text.len()
505        }
506
507        fn with_temperature(self, _temp: f32) -> Self
508        where
509            Self: Sized,
510        {
511            self
512        }
513
514        fn with_max_tokens(self, _max: usize) -> Self
515        where
516            Self: Sized,
517        {
518            self
519        }
520    }
521
522    #[async_trait]
523    impl BaseChatModel for BlockingChatModel {
524        async fn chat(
525            &self,
526            messages: Vec<Message>,
527            config: Option<RunnableConfig>,
528        ) -> Result<LLMResult, LcelError> {
529            self.invoke(messages, config).await
530        }
531
532        async fn stream_chat(
533            &self,
534            _messages: Vec<Message>,
535            _config: Option<RunnableConfig>,
536        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LcelError>> + Send>>, LcelError>
537        {
538            unimplemented!("stream_chat not needed for tests")
539        }
540    }
541
542    /// The session cache has a cap: the oldest session is evicted when exceeded, preventing a memory DoS from unbounded session_id growth (M2a).
543    #[tokio::test]
544    async fn session_cache_evicts_oldest_when_over_capacity() {
545        // Arrange
546        let seen = Arc::new(StdMutex::new(Vec::new()));
547        let llm = TestChatModel { seen: seen.clone() };
548        let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory)
549            .with_max_sessions(2);
550
551        let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
552        let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
553        let cfg_s3 = RunnableConfig::new().with_configurable("session_id", json!("s3"));
554
555        // Act s1 and s2 fill the cache; s3 triggers eviction of the oldest, s1.
556        pipe.invoke("s1-turn1".to_string(), Some(cfg_s1.clone()))
557            .await
558            .unwrap();
559        pipe.invoke("s2-turn1".to_string(), Some(cfg_s2))
560            .await
561            .unwrap();
562        pipe.invoke("s3-turn1".to_string(), Some(cfg_s3))
563            .await
564            .unwrap();
565        // s1 was evicted → re-entering s1 starts a fresh session.
566        pipe.invoke("s1-turn2".to_string(), Some(cfg_s1))
567            .await
568            .unwrap();
569
570        // Assert
571        let calls = seen.lock().unwrap();
572        assert_eq!(calls.len(), 4);
573        assert_eq!(
574            calls[3].len(),
575            1,
576            "M2a: re-entering s1 after eviction should be a fresh session (no history)"
577        );
578    }
579
580    /// Concurrent invokes on the same memory slot must serialize: hold the lock across the whole
581    /// read→LLM→write, so concurrent calls cannot both read stale history and overwrite each
582    /// other's write-back, losing context (M2b).
583    #[tokio::test]
584    async fn concurrent_same_session_invokes_do_not_lose_history() {
585        // Arrange
586        let seen = Arc::new(StdMutex::new(Vec::new()));
587        let entered = Arc::new(tokio::sync::Notify::new());
588        let release = Arc::new(tokio::sync::Notify::new());
589        let blocked = Arc::new(StdMutex::new(false));
590        let llm = BlockingChatModel {
591            seen: seen.clone(),
592            entered: entered.clone(),
593            release: release.clone(),
594            blocked,
595        };
596        let pipe = Arc::new(RunnableWithMessageHistory::new(
597            llm,
598            ConversationBufferMemory::new().with_return_messages(true),
599        ));
600
601        // Act first invoke: blocks after entering the model call — the memory lock must be held meanwhile.
602        let p1 = pipe.clone();
603        let h1 = tokio::spawn(async move { p1.invoke("第一轮".to_string(), None).await });
604        entered.notified().await;
605
606        // Second invoke: if read→LLM→write were not lock-held end-to-end, this call would read empty history (lost context).
607        let p2 = pipe.clone();
608        let h2 = tokio::spawn(async move { p2.invoke("第二轮".to_string(), None).await });
609
610        // Release the first turn: the lock is only released after write-back, so the second turn reads the first turn's full conversation.
611        release.notify_one();
612        let r1 = h1.await.unwrap().unwrap();
613        let r2 = h2.await.unwrap().unwrap();
614        assert_eq!(r1.content, "reply to: 第一轮");
615        assert_eq!(r2.content, "reply to: 第二轮");
616
617        // Assert
618        let calls = seen.lock().unwrap();
619        assert_eq!(calls.len(), 2);
620        assert_eq!(
621            calls[1].len(),
622            3,
623            "M2b: second turn should see the full first-turn conversation (user+ai+user), not empty history"
624        );
625        assert_eq!(calls[1][0].content, "第一轮");
626        assert_eq!(calls[1][2].content, "第二轮");
627    }
628}