1use 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
234pub 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 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); 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}