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, §ion_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(§ion_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(§ion_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}