Skip to main content

aacode_rs/tools/
session_tools.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Session management tools — list/switch/delete/history/stats/new/continue.
5//!
6//! These operate on the on-disk session store via a fresh `SessionManager`
7//! bound to the project path. Switching the *active* session is coordinated at
8//! the FFI/CLI layer; these tools report information the model can act on.
9//!
10//! Ported from Python `core/main_agent.py` session tools.
11
12use super::registry::Tool;
13use super::schema::{ParamType, ToolParameter, ToolSchema};
14use crate::error::{AacodeError, Result};
15use crate::session::SessionManager;
16use serde_json::{json, Value};
17use std::path::PathBuf;
18use std::sync::atomic::AtomicBool;
19use std::sync::{Arc, Mutex};
20
21pub struct ListSessionsTool {
22    pub project_path: PathBuf,
23    pub active_session_id: Arc<Mutex<Option<String>>>,
24}
25#[async_trait::async_trait]
26impl Tool for ListSessionsTool {
27    fn schema(&self) -> ToolSchema {
28        ToolSchema::new(
29            "list_sessions",
30            "List all conversation sessions. The active (current) session is marked with `current: true`, and its id is returned as `current_session_id`.",
31            vec![],
32        )
33    }
34    async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
35        let project_path = self.project_path.clone();
36        let current = self.active_session_id.lock().ok().and_then(|g| g.clone());
37        tokio::task::spawn_blocking(move || -> Result<String> {
38            let sm = SessionManager::new(&project_path);
39            let sessions: Vec<Value> = sm
40                .list_sessions()
41                .into_iter()
42                .map(|s| {
43                    let is_current = current.as_deref() == Some(s.session_id.as_str());
44                    json!({
45                        "session_id": s.session_id,
46                        "title": s.title,
47                        "created_at": s.created_at,
48                        "last_activity": s.last_activity,
49                        "total_messages": s.total_messages,
50                        "status": s.status,
51                        "current": is_current,
52                    })
53                })
54                .collect();
55            Ok(json!({
56                "success": true,
57                "count": sessions.len(),
58                "current_session_id": current,
59                "sessions": sessions,
60            })
61            .to_string())
62        })
63        .await
64        .map_err(|e| AacodeError::Other(e.to_string()))?
65    }
66}
67
68pub struct GetConversationHistoryTool {
69    pub project_path: PathBuf,
70    pub active_session_id: Arc<Mutex<Option<String>>>,
71}
72#[async_trait::async_trait]
73impl Tool for GetConversationHistoryTool {
74    fn schema(&self) -> ToolSchema {
75        ToolSchema::new(
76            "get_conversation_history",
77            "Read messages from a session. Returns role, content, timestamp, tool_calls, tool_call_id, and reasoning_content for each message. Fields longer than max_content_chars chars are truncated with a length marker.",
78            vec![
79                ToolParameter::new("session_id", ParamType::String, false, "Session id (defaults to current)", &["id"]),
80                ToolParameter::new("range_from", ParamType::Integer, false, "Start index (0 = earliest message)", &["from"]),
81                ToolParameter::new("range_to", ParamType::Integer, false, "End index exclusive (default = latest)", &["to"]),
82                ToolParameter::new("max_content_chars", ParamType::Integer, false, "Max chars per field before truncation", &["limit"]),
83            ],
84        )
85    }
86    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
87        let id = args
88            .get("session_id")
89            .and_then(|v| v.as_str())
90            .map(|s| s.to_string());
91        let range_from = args.get("range_from").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
92        let range_to = args
93            .get("range_to")
94            .and_then(|v| v.as_u64())
95            .map(|n| n as usize);
96        let max_chars = args
97            .get("max_content_chars")
98            .and_then(|v| v.as_u64())
99            .unwrap_or(100) as usize;
100        let project_path = self.project_path.clone();
101        let active_id = self.active_session_id.lock().unwrap().clone();
102        tokio::task::spawn_blocking(move || -> Result<String> {
103            let sm = SessionManager::new(&project_path);
104            let id = id
105                .or(active_id)
106                .or_else(|| sm.list_sessions().first().map(|s| s.session_id.clone()));
107            let id = match id {
108                Some(i) => i,
109                None => return Ok(json!({"success": false, "error": "no sessions"}).to_string()),
110            };
111            let msgs = sm.read_session_messages(&id);
112            let total = msgs.len();
113            let to = range_to.unwrap_or(total).min(total);
114            let from = range_from.min(to);
115            let sliced = &msgs[from..to];
116
117            let history: Vec<Value> = sliced
118                .iter()
119                .map(|m| {
120                    let mut obj = json!({
121                        "role": m.role,
122                        "content": truncate_str(&m.content, max_chars),
123                        "timestamp": m.timestamp,
124                    });
125                    if let Some(ref tcs) = m.tool_calls {
126                        let calls: Vec<Value> = tcs
127                            .iter()
128                            .map(|tc| {
129                                json!({
130                                    "id": tc.id,
131                                    "name": tc.name,
132                                    "arguments": truncate_str(&tc.arguments, max_chars),
133                                })
134                            })
135                            .collect();
136                        obj["tool_calls"] = json!(calls);
137                    }
138                    if let Some(ref tci) = m.tool_call_id {
139                        obj["tool_call_id"] = json!(tci);
140                    }
141                    if let Some(ref rc) = m.reasoning_content {
142                        obj["reasoning_content"] = json!(truncate_str(rc, max_chars));
143                    }
144                    obj
145                })
146                .collect();
147
148            Ok(json!({
149                "success": true,
150                "session_id": id,
151                "total_messages": total,
152                "history": history,
153            })
154            .to_string())
155        })
156        .await
157        .map_err(|e| AacodeError::Other(e.to_string()))?
158    }
159}
160
161fn truncate_str(s: &str, max_chars: usize) -> String {
162    if max_chars == 0 || s.chars().count() <= max_chars {
163        return s.to_string();
164    }
165    let head: String = s.chars().take(max_chars).collect();
166    format!("{}…({} chars total)", head, s.chars().count())
167}
168
169pub struct GetSessionStatsTool {
170    pub project_path: PathBuf,
171}
172#[async_trait::async_trait]
173impl Tool for GetSessionStatsTool {
174    fn schema(&self) -> ToolSchema {
175        ToolSchema::new("get_session_stats", "Get statistics for sessions.", vec![])
176    }
177    async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
178        let project_path = self.project_path.clone();
179        tokio::task::spawn_blocking(move || -> Result<String> {
180            let sm = SessionManager::new(&project_path);
181            let sessions = sm.list_sessions();
182            let total_msgs: usize = sessions.iter().map(|s| s.total_messages).sum();
183            let total_tokens: usize = sessions.iter().map(|s| s.total_tokens).sum();
184            Ok(json!({
185                "success": true,
186                "stats": {
187                    "session_count": sessions.len(),
188                    "total_messages": total_msgs,
189                    "total_tokens": total_tokens,
190                }
191            })
192            .to_string())
193        })
194        .await
195        .map_err(|e| AacodeError::Other(e.to_string()))?
196    }
197}
198
199pub struct DeleteSessionTool {
200    pub project_path: PathBuf,
201}
202#[async_trait::async_trait]
203impl Tool for DeleteSessionTool {
204    fn schema(&self) -> ToolSchema {
205        ToolSchema::new(
206            "delete_session",
207            "Delete a conversation session by id.",
208            vec![ToolParameter::new(
209                "session_id",
210                ParamType::String,
211                true,
212                "Session id",
213                &["id"],
214            )],
215        )
216    }
217    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
218        let id = args
219            .get("session_id")
220            .and_then(|v| v.as_str())
221            .unwrap_or("")
222            .to_string();
223        let project_path = self.project_path.clone();
224        tokio::task::spawn_blocking(move || -> Result<String> {
225            let mut sm = SessionManager::new(&project_path);
226            let ok = sm.delete_session(&id).unwrap_or(false);
227            Ok(json!({"success": ok, "session_id": id}).to_string())
228        })
229        .await
230        .map_err(|e| AacodeError::Other(e.to_string()))?
231    }
232}
233
234/// Informational stubs for new/continue/switch. Actual active-session change is
235/// coordinated by the caller (CLI/FFI). These acknowledge the request.
236pub struct NewSessionTool {
237    pub project_path: PathBuf,
238}
239#[async_trait::async_trait]
240impl Tool for NewSessionTool {
241    fn schema(&self) -> ToolSchema {
242        ToolSchema::new(
243            "new_session",
244            "Request a new conversation session for the next task.",
245            vec![ToolParameter::new(
246                "task",
247                ParamType::String,
248                false,
249                "Initial task",
250                &[],
251            )],
252        )
253    }
254    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
255        let task = args
256            .get("task")
257            .and_then(|v| v.as_str())
258            .unwrap_or("New task")
259            .to_string();
260        let title = args
261            .get("title")
262            .and_then(|v| v.as_str())
263            .map(|s| s.to_string());
264        let project_path = self.project_path.clone();
265        tokio::task::spawn_blocking(move || -> Result<String> {
266            let mut sm = SessionManager::new(&project_path);
267            match sm.create_session(&task, title.as_deref()) {
268                Ok(id) => Ok(
269                    json!({"success": true, "session_id": id, "message": "New session created"})
270                        .to_string(),
271                ),
272                Err(e) => Ok(json!({"success": false, "error": e.to_string()}).to_string()),
273            }
274        })
275        .await
276        .map_err(|e| AacodeError::Other(e.to_string()))?
277    }
278}
279
280pub struct ContinueSessionTool {
281    pub project_path: PathBuf,
282}
283#[async_trait::async_trait]
284impl Tool for ContinueSessionTool {
285    fn schema(&self) -> ToolSchema {
286        ToolSchema::new(
287            "continue_session",
288            "Add a follow-up message to the current or specified session.",
289            vec![
290                ToolParameter::new("message", ParamType::String, true, "Follow-up message", &[]),
291                ToolParameter::new(
292                    "session_id",
293                    ParamType::String,
294                    false,
295                    "Target session id",
296                    &["id"],
297                ),
298            ],
299        )
300    }
301    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
302        let msg = args
303            .get("message")
304            .and_then(|v| v.as_str())
305            .unwrap_or("")
306            .to_string();
307        if msg.is_empty() {
308            return Ok(json!({"success": false, "error": "message required"}).to_string());
309        }
310        let sid = args
311            .get("session_id")
312            .and_then(|v| v.as_str())
313            .map(|s| s.to_string());
314        let project_path = self.project_path.clone();
315        tokio::task::spawn_blocking(move || -> Result<String> {
316            let mut sm = SessionManager::new(&project_path);
317            if let Some(ref id) = sid {
318                if !sm.switch_session(id).unwrap_or(false) {
319                    return Ok(
320                        json!({"success": false, "error": format!("session {id} not found")})
321                            .to_string(),
322                    );
323                }
324            }
325            if sm.current_session_id.is_none() {
326                let id = sm.create_session(&msg, None)?;
327                return Ok(json!({"success": true, "session_id": id}).to_string());
328            }
329            sm.add_message(crate::session::SessionMessage::from_chat(
330                &crate::llm::ChatMessage::user(msg),
331            ))?;
332            let id = sm.current_session_id.clone().unwrap_or_default();
333            Ok(json!({"success": true, "session_id": id}).to_string())
334        })
335        .await
336        .map_err(|e| AacodeError::Other(e.to_string()))?
337    }
338}
339
340pub struct SwitchSessionTool {
341    pub project_path: PathBuf,
342}
343#[async_trait::async_trait]
344impl Tool for SwitchSessionTool {
345    fn schema(&self) -> ToolSchema {
346        ToolSchema::new(
347            "switch_session",
348            "Switch to a specified conversation session.",
349            vec![ToolParameter::new(
350                "session_id",
351                ParamType::String,
352                true,
353                "Session id",
354                &["id"],
355            )],
356        )
357    }
358    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
359        let id = args
360            .get("session_id")
361            .and_then(|v| v.as_str())
362            .unwrap_or("")
363            .to_string();
364        let project_path = self.project_path.clone();
365        tokio::task::spawn_blocking(move || -> Result<String> {
366            let mut sm = SessionManager::new(&project_path);
367            let ok = sm.switch_session(&id).unwrap_or(false);
368            Ok(json!({"success": ok, "session_id": id}).to_string())
369        })
370        .await
371        .map_err(|e| AacodeError::Other(e.to_string()))?
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::llm::types::ToolCall;
379    use crate::llm::ChatMessage;
380    use crate::session::SessionManager;
381
382    fn tmp() -> PathBuf {
383        let d = std::env::temp_dir().join(format!(
384            "aacode_sesstool_{}_{}",
385            std::process::id(),
386            uuid::Uuid::new_v4().simple()
387        ));
388        std::fs::create_dir_all(&d).unwrap();
389        d
390    }
391
392    fn history_tool(path: PathBuf) -> GetConversationHistoryTool {
393        GetConversationHistoryTool {
394            project_path: path,
395            active_session_id: Arc::new(Mutex::new(None)),
396        }
397    }
398
399    #[tokio::test]
400    async fn list_and_stats() {
401        let d = tmp();
402        {
403            let mut sm = SessionManager::new(&d);
404            sm.create_session("task one", None).unwrap();
405        }
406        let cancel = AtomicBool::new(false);
407        let out = ListSessionsTool {
408            project_path: d.clone(),
409            active_session_id: Arc::new(Mutex::new(None)),
410        }
411        .call(&json!({}), &cancel)
412        .await
413        .unwrap();
414        let v: Value = serde_json::from_str(&out).unwrap();
415        assert!(v["count"].as_u64().unwrap() >= 1);
416        assert!(v.get("current_session_id").is_some());
417
418        let out = GetSessionStatsTool {
419            project_path: d.clone(),
420        }
421        .call(&json!({}), &cancel)
422        .await
423        .unwrap();
424        assert!(serde_json::from_str::<Value>(&out).unwrap()["success"] == true);
425    }
426
427    #[tokio::test]
428    async fn delete_session() {
429        let d = tmp();
430        let id = {
431            let mut sm = SessionManager::new(&d);
432            sm.create_session("x", None).unwrap()
433        };
434        let cancel = AtomicBool::new(false);
435        let out = DeleteSessionTool {
436            project_path: d.clone(),
437        }
438        .call(&json!({"session_id": id}), &cancel)
439        .await
440        .unwrap();
441        assert_eq!(
442            serde_json::from_str::<Value>(&out).unwrap()["success"],
443            true
444        );
445    }
446
447    #[tokio::test]
448    async fn history_no_sessions() {
449        let d = tmp();
450        let cancel = AtomicBool::new(false);
451        let out = history_tool(d).call(&json!({}), &cancel).await.unwrap();
452        assert_eq!(
453            serde_json::from_str::<Value>(&out).unwrap()["success"],
454            false
455        );
456    }
457
458    #[tokio::test]
459    async fn history_includes_tool_calls_and_reasoning() {
460        let d = tmp();
461        let id = {
462            let mut sm = SessionManager::new(&d);
463            sm.create_session("task", None).unwrap();
464            let assistant = ChatMessage::assistant_with_tools(
465                "I will run a command",
466                vec![ToolCall {
467                    id: "call_abc".into(),
468                    name: "run_shell".into(),
469                    arguments: r#"{"command":"ls"}"#.into(),
470                }],
471            );
472            let mut msg = crate::session::SessionMessage::from_chat(&assistant);
473            msg.reasoning_content = Some("I should check the directory".into());
474            sm.add_message(msg).unwrap();
475
476            sm.add_message(crate::session::SessionMessage::from_chat(
477                &ChatMessage::tool_result("call_abc", "file1.txt\nfile2.txt\n"),
478            ))
479            .unwrap();
480            sm.flush().unwrap();
481            sm.current_session_id.clone().unwrap()
482        };
483
484        let cancel = AtomicBool::new(false);
485        let out = history_tool(d.clone())
486            .call(&json!({"session_id": id}), &cancel)
487            .await
488            .unwrap();
489        let v: Value = serde_json::from_str(&out).unwrap();
490        assert_eq!(v["success"], true);
491        let history = v["history"].as_array().unwrap();
492
493        let asst = history
494            .iter()
495            .find(|m| m["role"] == "assistant" && m["tool_calls"].is_array())
496            .expect("assistant with tool_calls should be in history");
497
498        assert_eq!(asst["content"], "I will run a command");
499        assert_eq!(
500            asst["timestamp"].as_str().map(|s| !s.is_empty()),
501            Some(true)
502        );
503        let tcs = asst["tool_calls"].as_array().unwrap();
504        assert_eq!(tcs.len(), 1);
505        assert_eq!(tcs[0]["name"], "run_shell");
506        assert_eq!(tcs[0]["arguments"], r#"{"command":"ls"}"#);
507        assert_eq!(asst["reasoning_content"], "I should check the directory");
508
509        let tool = history
510            .iter()
511            .find(|m| m["role"] == "tool")
512            .expect("tool result should be in history");
513        assert_eq!(tool["content"], "file1.txt\nfile2.txt\n");
514        assert_eq!(tool["tool_call_id"], "call_abc");
515    }
516
517    #[tokio::test]
518    async fn history_truncates_long_content() {
519        let d = tmp();
520        let id = {
521            let mut sm = SessionManager::new(&d);
522            sm.create_session("task", None).unwrap();
523            let content = "A".repeat(500);
524            sm.add_message(crate::session::SessionMessage::from_chat(
525                &ChatMessage::assistant(content.clone()),
526            ))
527            .unwrap();
528            sm.flush().unwrap();
529            sm.current_session_id.clone().unwrap()
530        };
531
532        let cancel = AtomicBool::new(false);
533        let out = history_tool(d.clone())
534            .call(
535                &json!({"session_id": id, "max_content_chars": 100}),
536                &cancel,
537            )
538            .await
539            .unwrap();
540        let v: Value = serde_json::from_str(&out).unwrap();
541        let history = v["history"].as_array().unwrap();
542        let asst = history.iter().find(|m| m["role"] == "assistant").unwrap();
543        let content = asst["content"].as_str().unwrap();
544        assert!(
545            content.contains("…(500 chars total)"),
546            "should show truncation marker, got: {}",
547            content
548        );
549        assert_eq!(content.chars().take(100).collect::<String>().len(), 100);
550    }
551
552    #[tokio::test]
553    async fn history_truncates_tool_arguments() {
554        let d = tmp();
555        let id = {
556            let mut sm = SessionManager::new(&d);
557            sm.create_session("task", None).unwrap();
558            let long_args = format!(r#"{{"code":"{}"}}"#, "x".repeat(500));
559            let assistant = ChatMessage::assistant_with_tools(
560                "run",
561                vec![ToolCall {
562                    id: "c1".into(),
563                    name: "execute_python".into(),
564                    arguments: long_args.clone(),
565                }],
566            );
567            sm.add_message(crate::session::SessionMessage::from_chat(&assistant))
568                .unwrap();
569            sm.flush().unwrap();
570            sm.current_session_id.clone().unwrap()
571        };
572
573        let cancel = AtomicBool::new(false);
574        let out = history_tool(d.clone())
575            .call(
576                &json!({"session_id": id, "max_content_chars": 100}),
577                &cancel,
578            )
579            .await
580            .unwrap();
581        let v: Value = serde_json::from_str(&out).unwrap();
582        let history = v["history"].as_array().unwrap();
583        let asst = history
584            .iter()
585            .find(|m| m["role"] == "assistant" && m["tool_calls"].is_array())
586            .expect("assistant with tool_calls should be in history");
587        let tcs = asst["tool_calls"].as_array().unwrap();
588        let args = tcs[0]["arguments"].as_str().unwrap();
589        assert!(
590            args.contains("…("),
591            "tool_calls arguments should be truncated, got: {}",
592            args
593        );
594    }
595
596    #[tokio::test]
597    async fn history_range_from_and_to() {
598        let d = tmp();
599        let id = {
600            let mut sm = SessionManager::new(&d);
601            sm.create_session("task", None).unwrap();
602            for i in 0..10 {
603                sm.add_message(crate::session::SessionMessage::from_chat(
604                    &ChatMessage::user(format!("msg {}", i)),
605                ))
606                .unwrap();
607            }
608            sm.flush().unwrap();
609            sm.current_session_id.clone().unwrap()
610        };
611
612        let cancel = AtomicBool::new(false);
613        // range_from=2, range_to=5 should return messages 2,3,4 (3 messages)
614        let out = history_tool(d.clone())
615            .call(
616                &json!({"session_id": id, "range_from": 2, "range_to": 5}),
617                &cancel,
618            )
619            .await
620            .unwrap();
621        let v: Value = serde_json::from_str(&out).unwrap();
622        assert_eq!(v["success"], true);
623        assert_eq!(v["total_messages"], 11); // 1 user + 10 additions
624        let history = v["history"].as_array().unwrap();
625        assert_eq!(
626            history.len(),
627            3,
628            "range_from=2, range_to=5 should return 3 messages"
629        );
630        assert_eq!(history[0]["content"], "msg 1");
631        assert_eq!(history[2]["content"], "msg 3");
632    }
633
634    #[tokio::test]
635    async fn history_max_content_chars_zero_means_no_truncation() {
636        let d = tmp();
637        let id = {
638            let mut sm = SessionManager::new(&d);
639            sm.create_session("task", None).unwrap();
640            let content = "A".repeat(500);
641            sm.add_message(crate::session::SessionMessage::from_chat(
642                &ChatMessage::assistant(content.clone()),
643            ))
644            .unwrap();
645            sm.flush().unwrap();
646            sm.current_session_id.clone().unwrap()
647        };
648
649        let cancel = AtomicBool::new(false);
650        let out = history_tool(d.clone())
651            .call(&json!({"session_id": id, "max_content_chars": 0}), &cancel)
652            .await
653            .unwrap();
654        let v: Value = serde_json::from_str(&out).unwrap();
655        let history = v["history"].as_array().unwrap();
656        let asst = history.iter().find(|m| m["role"] == "assistant").unwrap();
657        let content = asst["content"].as_str().unwrap();
658        assert_eq!(
659            content.len(),
660            500,
661            "max_content_chars=0 should mean no truncation, got len {}",
662            content.len()
663        );
664    }
665
666    #[tokio::test]
667    async fn new_and_continue_ack() {
668        let d = tmp();
669        let cancel = AtomicBool::new(false);
670        let nt = NewSessionTool {
671            project_path: d.clone(),
672        };
673        let out = nt.call(&json!({"task": "test"}), &cancel).await.unwrap();
674        assert!(out.contains("New session created"));
675        let ct = ContinueSessionTool { project_path: d };
676        let out = ct
677            .call(&json!({"message": "followup"}), &cancel)
678            .await
679            .unwrap();
680        assert!(out.contains("true"));
681    }
682
683    #[tokio::test]
684    async fn history_uses_active_session_when_no_session_id() {
685        let d = tmp();
686        let active_id = {
687            let mut sm = SessionManager::new(&d);
688            sm.create_session("active session task", None).unwrap();
689            sm.current_session_id.clone().unwrap()
690        };
691
692        let cancel = AtomicBool::new(false);
693        let holder = Arc::new(Mutex::new(Some(active_id.clone())));
694        let tool = GetConversationHistoryTool {
695            project_path: d.clone(),
696            active_session_id: holder,
697        };
698        let out = tool.call(&json!({}), &cancel).await.unwrap();
699        let v: Value = serde_json::from_str(&out).unwrap();
700        assert_eq!(v["success"], true);
701        assert_eq!(v["session_id"], active_id);
702        assert_eq!(v["total_messages"], 1);
703    }
704}