Skip to main content

microclaw_engine/tools/
memory.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use std::sync::Arc;
5use tracing::info;
6
7use crate::internal::storage::db::Database;
8use crate::internal::storage::memory_quality;
9use crate::memory_backend::MemoryBackend;
10use microclaw_core::llm_types::ToolDefinition;
11
12use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
13
14pub struct ReadMemoryTool {
15    groups_dir: PathBuf,
16    db: Arc<Database>,
17}
18
19impl ReadMemoryTool {
20    pub fn new(data_dir: &str, db: Arc<Database>) -> Self {
21        ReadMemoryTool {
22            groups_dir: PathBuf::from(data_dir).join("groups"),
23            db,
24        }
25    }
26}
27
28fn memory_channel_from_auth(input: &serde_json::Value) -> String {
29    auth_context_from_input(input)
30        .map(|a| a.caller_channel)
31        .filter(|s| !s.trim().is_empty())
32        .unwrap_or_else(|| "web".to_string())
33}
34
35fn chat_id_from_input_or_auth(input: &serde_json::Value) -> Option<i64> {
36    input
37        .get("chat_id")
38        .and_then(|v| v.as_i64())
39        .or_else(|| auth_context_from_input(input).map(|a| a.caller_chat_id))
40}
41
42fn memory_channel_for_chat(
43    db: &Database,
44    input: &serde_json::Value,
45    chat_id: i64,
46) -> Result<String, String> {
47    if let Some(channel) = db
48        .get_chat_channel(chat_id)
49        .map_err(|e| format!("Failed to resolve chat channel for {chat_id}: {e}"))?
50        .map(|s| s.trim().to_string())
51        .filter(|s| !s.is_empty())
52    {
53        Ok(channel)
54    } else {
55        Ok(memory_channel_from_auth(input))
56    }
57}
58
59fn latest_sender_for_chat(db: &Database, chat_id: i64) -> Option<String> {
60    db.get_recent_messages(chat_id, 20)
61        .ok()?
62        .into_iter()
63        .rev()
64        .find(|m| !m.is_from_bot && !m.content.trim_start().starts_with('/'))
65        .map(|m| m.sender_name.trim().to_string())
66        .filter(|s| !s.is_empty())
67}
68
69fn find_person_section_range(lines: &[&str], header: &str) -> Option<(usize, usize)> {
70    let start = lines.iter().position(|line| line.trim() == header)?;
71    let end = lines
72        .iter()
73        .enumerate()
74        .skip(start + 1)
75        .find(|(_, line)| line.trim_start().starts_with("## Person:"))
76        .map(|(idx, _)| idx)
77        .unwrap_or(lines.len());
78    Some((start, end))
79}
80
81fn upsert_chat_person_memory(existing: &str, sender: &str, content: &str) -> String {
82    let sender = sender.trim();
83    let content = content.trim();
84    if sender.is_empty() || content.is_empty() {
85        return content.to_string();
86    }
87
88    let section_header = format!("## Person: {sender}");
89    let section_block = format!("{section_header}\n{content}\n");
90    let existing_trimmed = existing.trim();
91    if existing_trimmed.is_empty() {
92        return section_block;
93    }
94
95    let lines: Vec<&str> = existing_trimmed.lines().collect();
96    if let Some((start, end)) = find_person_section_range(&lines, &section_header) {
97        let mut out = String::new();
98        for line in &lines[..start] {
99            out.push_str(line);
100            out.push('\n');
101        }
102        out.push_str(&section_block);
103        for line in &lines[end..] {
104            out.push_str(line);
105            out.push('\n');
106        }
107        return out;
108    }
109
110    let mut out = String::new();
111    out.push_str(existing_trimmed);
112    if !existing_trimmed.ends_with('\n') {
113        out.push('\n');
114    }
115    out.push('\n');
116    out.push_str(&section_block);
117    out
118}
119
120#[async_trait]
121impl Tool for ReadMemoryTool {
122    fn name(&self) -> &str {
123        "read_memory"
124    }
125
126    fn definition(&self) -> ToolDefinition {
127        ToolDefinition {
128            name: "read_memory".into(),
129            description: "Read internal AGENTS.md memory context for reasoning. Use scope 'global' for memories shared across all chats, 'bot' for the current bot/account, or 'chat' for chat-specific memories. Do not echo raw memory blocks/IDs directly to users; summarize in natural language.".into(),
130            input_schema: schema_object(
131                json!({
132                    "scope": {
133                        "type": "string",
134                        "description": "Memory scope: 'global', 'bot', or 'chat'",
135                        "enum": ["global", "bot", "chat"]
136                    },
137                    "chat_id": {
138                        "type": "integer",
139                        "description": "Chat ID (required for scope 'chat')"
140                    }
141                }),
142                &["scope"],
143            ),
144        }
145    }
146
147    async fn execute(&self, input: serde_json::Value) -> ToolResult {
148        let scope = match input.get("scope").and_then(|v| v.as_str()) {
149            Some(s) => s,
150            None => return ToolResult::error("Missing 'scope' parameter".into()),
151        };
152
153        let path = match scope {
154            "global" => self.groups_dir.join("AGENTS.md"),
155            "bot" => {
156                let channel = memory_channel_from_auth(&input);
157                self.groups_dir.join(channel).join("AGENTS.md")
158            }
159            "chat" => {
160                let chat_id = match chat_id_from_input_or_auth(&input) {
161                    Some(id) => id,
162                    None => return ToolResult::error("Missing 'chat_id' for chat scope".into()),
163                };
164                if let Err(e) = authorize_chat_access(&input, chat_id) {
165                    return ToolResult::error(e);
166                }
167                let channel = match memory_channel_for_chat(&self.db, &input, chat_id) {
168                    Ok(v) => v,
169                    Err(e) => return ToolResult::error(e),
170                };
171                self.groups_dir
172                    .join(channel)
173                    .join(chat_id.to_string())
174                    .join("AGENTS.md")
175            }
176            _ => return ToolResult::error("scope must be 'global', 'bot', or 'chat'".into()),
177        };
178
179        info!("Reading memory: {}", path.display());
180
181        match std::fs::read_to_string(&path) {
182            Ok(content) => {
183                if content.trim().is_empty() {
184                    ToolResult::success("Memory file is empty.".into())
185                } else {
186                    ToolResult::success(content)
187                }
188            }
189            Err(_) => ToolResult::success("No memory file found (not yet created).".into()),
190        }
191    }
192}
193
194pub struct WriteMemoryTool {
195    groups_dir: PathBuf,
196    db: Arc<Database>,
197    memory_backend: Arc<MemoryBackend>,
198}
199
200impl WriteMemoryTool {
201    pub fn new(data_dir: &str, db: Arc<Database>, memory_backend: Arc<MemoryBackend>) -> Self {
202        WriteMemoryTool {
203            groups_dir: PathBuf::from(data_dir).join("groups"),
204            db,
205            memory_backend,
206        }
207    }
208}
209
210#[async_trait]
211impl Tool for WriteMemoryTool {
212    fn name(&self) -> &str {
213        "write_memory"
214    }
215
216    fn definition(&self) -> ToolDefinition {
217        ToolDefinition {
218            name: "write_memory".into(),
219            description: "Write to the AGENTS.md memory file. Use this to remember important information about the user or conversation. Use scope 'global' for memories shared across all chats, 'bot' for the current bot/account, or 'chat' for chat-specific memories. In chat scope, memory is stored per person when sender identity is available. Pass `ttl_days` for facts that should expire (e.g. \"working from Tokyo this week\", \"travel until 2026-05-20\") so the memory is auto-pruned later.".into(),
220            input_schema: schema_object(
221                json!({
222                    "scope": {
223                        "type": "string",
224                        "description": "Memory scope: 'global', 'bot', or 'chat'",
225                        "enum": ["global", "bot", "chat"]
226                    },
227                    "chat_id": {
228                        "type": "integer",
229                        "description": "Chat ID (required for scope 'chat')"
230                    },
231                    "content": {
232                        "type": "string",
233                        "description": "The content to write to the memory file (global/bot replace full file; chat updates the latest sender section when sender identity is available)"
234                    },
235                    "ttl_days": {
236                        "type": "number",
237                        "description": "Optional. If set, the structured memory row is auto-pruned after this many days. Use for time-bounded facts; omit for durable knowledge."
238                    }
239                }),
240                &["scope", "content"],
241            ),
242        }
243    }
244
245    async fn execute(&self, input: serde_json::Value) -> ToolResult {
246        let scope = match input.get("scope").and_then(|v| v.as_str()) {
247            Some(s) => s,
248            None => return ToolResult::error("Missing 'scope' parameter".into()),
249        };
250        let content = match input.get("content").and_then(|v| v.as_str()) {
251            Some(c) => c,
252            None => return ToolResult::error("Missing 'content' parameter".into()),
253        };
254
255        let (path, memory_chat_id) = match scope {
256            "global" => {
257                if let Some(auth) = auth_context_from_input(&input) {
258                    if !auth.is_control_chat() {
259                        return ToolResult::error(format!(
260                            "Permission denied: chat {} cannot write global memory",
261                            auth.caller_chat_id
262                        ));
263                    }
264                }
265                (self.groups_dir.join("AGENTS.md"), None)
266            }
267            "bot" => {
268                let channel = memory_channel_from_auth(&input);
269                (self.groups_dir.join(channel).join("AGENTS.md"), None)
270            }
271            "chat" => {
272                let chat_id = match chat_id_from_input_or_auth(&input) {
273                    Some(id) => id,
274                    None => return ToolResult::error("Missing 'chat_id' for chat scope".into()),
275                };
276                if let Err(e) = authorize_chat_access(&input, chat_id) {
277                    return ToolResult::error(e);
278                }
279                let channel = match memory_channel_for_chat(&self.db, &input, chat_id) {
280                    Ok(v) => v,
281                    Err(e) => return ToolResult::error(e),
282                };
283                (
284                    self.groups_dir
285                        .join(channel)
286                        .join(chat_id.to_string())
287                        .join("AGENTS.md"),
288                    Some(chat_id),
289                )
290            }
291            _ => return ToolResult::error("scope must be 'global', 'bot', or 'chat'".into()),
292        };
293
294        info!("Writing memory: {}", path.display());
295
296        if let Some(parent) = path.parent() {
297            if let Err(e) = std::fs::create_dir_all(parent) {
298                return ToolResult::error(format!("Failed to create directory: {e}"));
299            }
300        }
301
302        let write_content = if scope == "chat" {
303            let chat_id = memory_chat_id.unwrap_or_default();
304            let sender = latest_sender_for_chat(&self.db, chat_id);
305            let existing = std::fs::read_to_string(&path).unwrap_or_default();
306            if let Some(sender) = sender {
307                upsert_chat_person_memory(&existing, &sender, content)
308            } else {
309                content.to_string()
310            }
311        } else {
312            content.to_string()
313        };
314
315        match std::fs::write(&path, &write_content) {
316            Ok(()) => {
317                let memory_content = content.trim().to_string();
318                let ttl_days = input
319                    .get("ttl_days")
320                    .and_then(|v| v.as_f64())
321                    .filter(|v| v.is_finite() && *v > 0.0);
322                if !memory_content.is_empty() {
323                    if let Some(normalized) =
324                        memory_quality::normalize_memory_content(&memory_content, 180)
325                    {
326                        if memory_quality::memory_quality_ok(&normalized) {
327                            let chat_id = memory_chat_id;
328                            if let Ok(memory_id) = self
329                                .memory_backend
330                                .insert_memory_with_metadata(
331                                    chat_id,
332                                    &normalized,
333                                    "KNOWLEDGE",
334                                    "write_memory_tool",
335                                    0.85,
336                                )
337                                .await
338                            {
339                                if let Some(days) = ttl_days {
340                                    let secs = (days * 86_400.0) as i64;
341                                    let expires_at = (chrono::Utc::now()
342                                        + chrono::Duration::seconds(secs))
343                                    .to_rfc3339();
344                                    let _ =
345                                        self.db.set_memory_expires_at(memory_id, Some(&expires_at));
346                                }
347                            }
348                        }
349                    }
350                }
351
352                let suffix = match ttl_days {
353                    Some(d) => format!(" (expires in {d} day(s))"),
354                    None => String::new(),
355                };
356                ToolResult::success(format!("Memory saved to {} scope.{}", scope, suffix))
357            }
358            Err(e) => ToolResult::error(format!("Failed to write memory: {e}")),
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use serde_json::json;
367    use std::sync::Arc;
368
369    use crate::internal::storage::db::{Database, StoredMessage};
370
371    fn test_dir() -> std::path::PathBuf {
372        std::env::temp_dir().join(format!("microclaw_memtool_{}", uuid::Uuid::new_v4()))
373    }
374
375    fn test_db(dir: &std::path::Path) -> Arc<Database> {
376        let runtime = dir.join("runtime");
377        std::fs::create_dir_all(&runtime).unwrap();
378        Arc::new(Database::new(runtime.to_str().unwrap()).unwrap())
379    }
380
381    fn test_backend(db: Arc<Database>) -> Arc<MemoryBackend> {
382        Arc::new(MemoryBackend::local_only(db))
383    }
384
385    fn store_user_message(db: &Database, chat_id: i64, sender_name: &str, content: &str) {
386        let msg = StoredMessage {
387            id: format!("{}-{}", sender_name, uuid::Uuid::new_v4()),
388            chat_id,
389            sender_name: sender_name.to_string(),
390            content: content.to_string(),
391            is_from_bot: false,
392            timestamp: chrono::Utc::now().to_rfc3339(),
393        };
394        db.store_message(&msg).unwrap();
395    }
396
397    #[tokio::test]
398    async fn test_read_memory_global_not_exists() {
399        let dir = test_dir();
400        let db = test_db(&dir);
401        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
402        let result = tool.execute(json!({"scope": "global"})).await;
403        assert!(!result.is_error);
404        assert!(result.content.contains("No memory file found"));
405        let _ = std::fs::remove_dir_all(&dir);
406    }
407
408    #[tokio::test]
409    async fn test_write_and_read_memory_global() {
410        let dir = test_dir();
411        let db = test_db(&dir);
412        let write_tool =
413            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
414        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db.clone());
415
416        let result = write_tool
417            .execute(json!({"scope": "global", "content": "user prefers Rust"}))
418            .await;
419        assert!(!result.is_error);
420        assert!(result.content.contains("Memory saved"));
421
422        let result = read_tool.execute(json!({"scope": "global"})).await;
423        assert!(!result.is_error);
424        assert_eq!(result.content, "user prefers Rust");
425        let mems = db.get_all_memories_for_chat(None).unwrap();
426        assert_eq!(mems.len(), 1);
427        assert_eq!(mems[0].content, "user prefers Rust");
428
429        let _ = std::fs::remove_dir_all(&dir);
430    }
431
432    #[tokio::test]
433    async fn test_write_and_read_memory_chat() {
434        let dir = test_dir();
435        let db = test_db(&dir);
436        db.resolve_or_create_chat_id("web", "42", Some("web-42"), "web")
437            .unwrap();
438        let write_tool =
439            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
440        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db.clone());
441
442        let result = write_tool
443            .execute(json!({"scope": "chat", "chat_id": 42, "content": "chat 42 notes"}))
444            .await;
445        assert!(!result.is_error);
446
447        let result = read_tool
448            .execute(json!({"scope": "chat", "chat_id": 42}))
449            .await;
450        assert!(!result.is_error);
451        assert_eq!(result.content, "chat 42 notes");
452        let mems = db.get_all_memories_for_chat(Some(42)).unwrap();
453        assert_eq!(mems.len(), 1);
454        assert_eq!(mems[0].content, "chat 42 notes");
455
456        let _ = std::fs::remove_dir_all(&dir);
457    }
458
459    #[tokio::test]
460    async fn test_read_memory_chat_missing_chat_id() {
461        let dir = test_dir();
462        let db = test_db(&dir);
463        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
464        let result = tool.execute(json!({"scope": "chat"})).await;
465        assert!(result.is_error);
466        assert!(result.content.contains("Missing 'chat_id'"));
467        let _ = std::fs::remove_dir_all(&dir);
468    }
469
470    #[tokio::test]
471    async fn test_write_memory_chat_uses_auth_chat_id_when_missing() {
472        let dir = test_dir();
473        let db = test_db(&dir);
474        db.resolve_or_create_chat_id("web", "42", Some("web-42"), "web")
475            .unwrap();
476        let tool =
477            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
478        let result = tool
479            .execute(json!({
480                "scope": "chat",
481                "content": "from auth chat id",
482                "__microclaw_auth": {
483                    "caller_channel": "web",
484                    "caller_chat_id": 42,
485                    "control_chat_ids": []
486                }
487            }))
488            .await;
489        assert!(!result.is_error, "{}", result.content);
490        let content =
491            std::fs::read_to_string(dir.join("groups").join("web").join("42").join("AGENTS.md"))
492                .unwrap();
493        assert_eq!(content, "from auth chat id");
494        let _ = std::fs::remove_dir_all(&dir);
495    }
496
497    #[tokio::test]
498    async fn test_write_memory_chat_upserts_per_sender() {
499        let dir = test_dir();
500        let db = test_db(&dir);
501        db.resolve_or_create_chat_id("web", "42", Some("web-42"), "web")
502            .unwrap();
503        let tool =
504            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
505
506        store_user_message(&db, 42, "alice", "remember profile");
507        let r1 = tool
508            .execute(json!({"scope": "chat", "chat_id": 42, "content": "昵称: 老板"}))
509            .await;
510        assert!(!r1.is_error, "{}", r1.content);
511
512        store_user_message(&db, 42, "bob", "remember profile");
513        let r2 = tool
514            .execute(json!({"scope": "chat", "chat_id": 42, "content": "昵称: Bob哥"}))
515            .await;
516        assert!(!r2.is_error, "{}", r2.content);
517
518        store_user_message(&db, 42, "alice", "update profile");
519        let r3 = tool
520            .execute(json!({"scope": "chat", "chat_id": 42, "content": "昵称: 大老板"}))
521            .await;
522        assert!(!r3.is_error, "{}", r3.content);
523
524        let content =
525            std::fs::read_to_string(dir.join("groups").join("web").join("42").join("AGENTS.md"))
526                .unwrap();
527        assert!(content.contains("## Person: alice"));
528        assert!(content.contains("## Person: bob"));
529        assert!(content.contains("昵称: 大老板"));
530        assert!(content.contains("昵称: Bob哥"));
531        assert!(!content.contains("昵称: 老板"));
532
533        let _ = std::fs::remove_dir_all(&dir);
534    }
535
536    #[tokio::test]
537    async fn test_write_memory_missing_scope() {
538        let dir = test_dir();
539        let db = test_db(&dir);
540        let tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
541        let result = tool.execute(json!({"content": "data"})).await;
542        assert!(result.is_error);
543        assert!(result.content.contains("Missing 'scope'"));
544        let _ = std::fs::remove_dir_all(&dir);
545    }
546
547    #[tokio::test]
548    async fn test_read_memory_invalid_scope() {
549        let dir = test_dir();
550        let db = test_db(&dir);
551        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
552        let result = tool.execute(json!({"scope": "invalid"})).await;
553        assert!(result.is_error);
554        assert!(result
555            .content
556            .contains("must be 'global', 'bot', or 'chat'"));
557        let _ = std::fs::remove_dir_all(&dir);
558    }
559
560    #[tokio::test]
561    async fn test_write_and_read_memory_bot_scope() {
562        let dir = test_dir();
563        let db = test_db(&dir);
564        let write_tool =
565            WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db.clone()));
566        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
567
568        let write = write_tool
569            .execute(json!({
570                "scope": "bot",
571                "content": "bot identity",
572                "__microclaw_auth": {
573                    "caller_channel": "feishu.ops",
574                    "caller_chat_id": 42,
575                    "control_chat_ids": []
576                }
577            }))
578            .await;
579        assert!(!write.is_error, "{}", write.content);
580
581        let read = read_tool
582            .execute(json!({
583                "scope": "bot",
584                "__microclaw_auth": {
585                    "caller_channel": "feishu.ops",
586                    "caller_chat_id": 42,
587                    "control_chat_ids": []
588                }
589            }))
590            .await;
591        assert!(!read.is_error, "{}", read.content);
592        assert_eq!(read.content, "bot identity");
593        let _ = std::fs::remove_dir_all(&dir);
594    }
595
596    #[tokio::test]
597    async fn test_read_memory_empty_file() {
598        let dir = test_dir();
599        let db = test_db(&dir);
600        let write_tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
601        let db2 = test_db(&dir);
602        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db2);
603
604        write_tool
605            .execute(json!({"scope": "global", "content": "   "}))
606            .await;
607
608        let result = read_tool.execute(json!({"scope": "global"})).await;
609        assert!(!result.is_error);
610        assert!(result.content.contains("empty"));
611
612        let _ = std::fs::remove_dir_all(&dir);
613    }
614
615    #[tokio::test]
616    async fn test_write_memory_global_denied_for_non_control_chat() {
617        let dir = test_dir();
618        let db = test_db(&dir);
619        let tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
620        let result = tool
621            .execute(json!({
622                "scope": "global",
623                "content": "secret",
624                "__microclaw_auth": {
625                    "caller_chat_id": 100,
626                    "control_chat_ids": []
627                }
628            }))
629            .await;
630        assert!(result.is_error);
631        assert!(result.content.contains("Permission denied"));
632        let _ = std::fs::remove_dir_all(&dir);
633    }
634
635    #[tokio::test]
636    async fn test_write_memory_global_allowed_for_control_chat() {
637        let dir = test_dir();
638        let db = test_db(&dir);
639        let tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
640        let result = tool
641            .execute(json!({
642                "scope": "global",
643                "content": "global ok",
644                "__microclaw_auth": {
645                    "caller_chat_id": 100,
646                    "control_chat_ids": [100]
647                }
648            }))
649            .await;
650        assert!(!result.is_error, "{}", result.content);
651        let content = std::fs::read_to_string(dir.join("groups").join("AGENTS.md")).unwrap();
652        assert_eq!(content, "global ok");
653        let _ = std::fs::remove_dir_all(&dir);
654    }
655
656    #[tokio::test]
657    async fn test_read_memory_chat_permission_denied() {
658        let dir = test_dir();
659        let db = test_db(&dir);
660        let tool = ReadMemoryTool::new(dir.to_str().unwrap(), db);
661        let result = tool
662            .execute(json!({
663                "scope": "chat",
664                "chat_id": 200,
665                "__microclaw_auth": {
666                    "caller_chat_id": 100,
667                    "control_chat_ids": []
668                }
669            }))
670            .await;
671        assert!(result.is_error);
672        assert!(result.content.contains("Permission denied"));
673        let _ = std::fs::remove_dir_all(&dir);
674    }
675
676    #[tokio::test]
677    async fn test_read_memory_chat_allowed_for_control_chat_cross_chat() {
678        let dir = test_dir();
679        let db = test_db(&dir);
680        db.resolve_or_create_chat_id("web", "200", Some("web-200"), "web")
681            .unwrap();
682        let write_tool = WriteMemoryTool::new(dir.to_str().unwrap(), db.clone(), test_backend(db));
683        let db2 = test_db(&dir);
684        let read_tool = ReadMemoryTool::new(dir.to_str().unwrap(), db2);
685        write_tool
686            .execute(json!({"scope": "chat", "chat_id": 200, "content": "chat200"}))
687            .await;
688        let result = read_tool
689            .execute(json!({
690                "scope": "chat",
691                "chat_id": 200,
692                "__microclaw_auth": {
693                    "caller_chat_id": 100,
694                    "control_chat_ids": [100]
695                }
696            }))
697            .await;
698        assert!(!result.is_error, "{}", result.content);
699        assert_eq!(result.content, "chat200");
700        let _ = std::fs::remove_dir_all(&dir);
701    }
702}