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}
24#[async_trait::async_trait]
25impl Tool for ListSessionsTool {
26 fn schema(&self) -> ToolSchema {
27 ToolSchema::new("list_sessions", "List all conversation sessions.", vec![])
28 }
29 async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
30 let project_path = self.project_path.clone();
31 tokio::task::spawn_blocking(move || -> Result<String> {
32 let sm = SessionManager::new(&project_path);
33 let sessions: Vec<Value> = sm
34 .list_sessions()
35 .into_iter()
36 .map(|s| {
37 json!({
38 "session_id": s.session_id,
39 "title": s.title,
40 "created_at": s.created_at,
41 "last_activity": s.last_activity,
42 "total_messages": s.total_messages,
43 "status": s.status,
44 })
45 })
46 .collect();
47 Ok(json!({"success": true, "count": sessions.len(), "sessions": sessions}).to_string())
48 })
49 .await
50 .map_err(|e| AacodeError::Other(e.to_string()))?
51 }
52}
53
54pub struct GetConversationHistoryTool {
55 pub project_path: PathBuf,
56 pub active_session_id: Arc<Mutex<Option<String>>>,
57}
58#[async_trait::async_trait]
59impl Tool for GetConversationHistoryTool {
60 fn schema(&self) -> ToolSchema {
61 ToolSchema::new(
62 "get_conversation_history",
63 "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.",
64 vec![
65 ToolParameter::new("session_id", ParamType::String, false, "Session id (defaults to current)", &["id"]),
66 ToolParameter::new("range_from", ParamType::Integer, false, "Start index (0 = earliest message)", &["from"]),
67 ToolParameter::new("range_to", ParamType::Integer, false, "End index exclusive (default = latest)", &["to"]),
68 ToolParameter::new("max_content_chars", ParamType::Integer, false, "Max chars per field before truncation", &["limit"]),
69 ],
70 )
71 }
72 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
73 let id = args.get("session_id").and_then(|v| v.as_str()).map(|s| s.to_string());
74 let range_from = args.get("range_from").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
75 let range_to = args.get("range_to").and_then(|v| v.as_u64()).map(|n| n as usize);
76 let max_chars = args.get("max_content_chars").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
77 let project_path = self.project_path.clone();
78 let active_id = self.active_session_id.lock().unwrap().clone();
79 tokio::task::spawn_blocking(move || -> Result<String> {
80 let sm = SessionManager::new(&project_path);
81 let id = id
82 .or(active_id)
83 .or_else(|| sm.list_sessions().first().map(|s| s.session_id.clone()));
84 let id = match id {
85 Some(i) => i,
86 None => return Ok(json!({"success": false, "error": "no sessions"}).to_string()),
87 };
88 let msgs = sm.read_session_messages(&id);
89 let total = msgs.len();
90 let to = range_to.unwrap_or(total).min(total);
91 let from = range_from.min(to);
92 let sliced = &msgs[from..to];
93
94 let history: Vec<Value> = sliced
95 .iter()
96 .map(|m| {
97 let mut obj = json!({
98 "role": m.role,
99 "content": truncate_str(&m.content, max_chars),
100 "timestamp": m.timestamp,
101 });
102 if let Some(ref tcs) = m.tool_calls {
103 let calls: Vec<Value> = tcs.iter().map(|tc| json!({
104 "id": tc.id,
105 "name": tc.name,
106 "arguments": truncate_str(&tc.arguments, max_chars),
107 })).collect();
108 obj["tool_calls"] = json!(calls);
109 }
110 if let Some(ref tci) = m.tool_call_id {
111 obj["tool_call_id"] = json!(tci);
112 }
113 if let Some(ref rc) = m.reasoning_content {
114 obj["reasoning_content"] = json!(truncate_str(rc, max_chars));
115 }
116 obj
117 })
118 .collect();
119
120 Ok(json!({
121 "success": true,
122 "session_id": id,
123 "total_messages": total,
124 "history": history,
125 }).to_string())
126 })
127 .await
128 .map_err(|e| AacodeError::Other(e.to_string()))?
129 }
130}
131
132fn truncate_str(s: &str, max_chars: usize) -> String {
133 if max_chars == 0 || s.chars().count() <= max_chars {
134 return s.to_string();
135 }
136 let head: String = s.chars().take(max_chars).collect();
137 format!("{}…({} chars total)", head, s.chars().count())
138}
139
140pub struct GetSessionStatsTool {
141 pub project_path: PathBuf,
142}
143#[async_trait::async_trait]
144impl Tool for GetSessionStatsTool {
145 fn schema(&self) -> ToolSchema {
146 ToolSchema::new("get_session_stats", "Get statistics for sessions.", vec![])
147 }
148 async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
149 let project_path = self.project_path.clone();
150 tokio::task::spawn_blocking(move || -> Result<String> {
151 let sm = SessionManager::new(&project_path);
152 let sessions = sm.list_sessions();
153 let total_msgs: usize = sessions.iter().map(|s| s.total_messages).sum();
154 let total_tokens: usize = sessions.iter().map(|s| s.total_tokens).sum();
155 Ok(json!({
156 "success": true,
157 "stats": {
158 "session_count": sessions.len(),
159 "total_messages": total_msgs,
160 "total_tokens": total_tokens,
161 }
162 })
163 .to_string())
164 })
165 .await
166 .map_err(|e| AacodeError::Other(e.to_string()))?
167 }
168}
169
170pub struct DeleteSessionTool {
171 pub project_path: PathBuf,
172}
173#[async_trait::async_trait]
174impl Tool for DeleteSessionTool {
175 fn schema(&self) -> ToolSchema {
176 ToolSchema::new(
177 "delete_session",
178 "Delete a conversation session by id.",
179 vec![ToolParameter::new("session_id", ParamType::String, true, "Session id", &["id"])],
180 )
181 }
182 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
183 let id = args.get("session_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
184 let project_path = self.project_path.clone();
185 tokio::task::spawn_blocking(move || -> Result<String> {
186 let mut sm = SessionManager::new(&project_path);
187 let ok = sm.delete_session(&id).unwrap_or(false);
188 Ok(json!({"success": ok, "session_id": id}).to_string())
189 })
190 .await
191 .map_err(|e| AacodeError::Other(e.to_string()))?
192 }
193}
194
195pub struct NewSessionTool {
198 pub project_path: PathBuf,
199}
200#[async_trait::async_trait]
201impl Tool for NewSessionTool {
202 fn schema(&self) -> ToolSchema {
203 ToolSchema::new(
204 "new_session",
205 "Request a new conversation session for the next task.",
206 vec![ToolParameter::new("task", ParamType::String, false, "Initial task", &[])],
207 )
208 }
209 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
210 let task = args.get("task").and_then(|v| v.as_str()).unwrap_or("New task").to_string();
211 let title = args.get("title").and_then(|v| v.as_str()).map(|s| s.to_string());
212 let project_path = self.project_path.clone();
213 tokio::task::spawn_blocking(move || -> Result<String> {
214 let mut sm = SessionManager::new(&project_path);
215 match sm.create_session(&task, title.as_deref()) {
216 Ok(id) => Ok(json!({"success": true, "session_id": id, "message": "New session created"}).to_string()),
217 Err(e) => Ok(json!({"success": false, "error": e.to_string()}).to_string()),
218 }
219 })
220 .await
221 .map_err(|e| AacodeError::Other(e.to_string()))?
222 }
223}
224
225pub struct ContinueSessionTool {
226 pub project_path: PathBuf,
227}
228#[async_trait::async_trait]
229impl Tool for ContinueSessionTool {
230 fn schema(&self) -> ToolSchema {
231 ToolSchema::new(
232 "continue_session",
233 "Add a follow-up message to the current or specified session.",
234 vec![
235 ToolParameter::new("message", ParamType::String, true, "Follow-up message", &[]),
236 ToolParameter::new("session_id", ParamType::String, false, "Target session id", &["id"]),
237 ],
238 )
239 }
240 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
241 let msg = args.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string();
242 if msg.is_empty() {
243 return Ok(json!({"success": false, "error": "message required"}).to_string());
244 }
245 let sid = args.get("session_id").and_then(|v| v.as_str()).map(|s| s.to_string());
246 let project_path = self.project_path.clone();
247 tokio::task::spawn_blocking(move || -> Result<String> {
248 let mut sm = SessionManager::new(&project_path);
249 if let Some(ref id) = sid {
250 if !sm.switch_session(id).unwrap_or(false) {
251 return Ok(json!({"success": false, "error": format!("session {id} not found")}).to_string());
252 }
253 }
254 if sm.current_session_id.is_none() {
255 let id = sm.create_session(&msg, None)?;
256 return Ok(json!({"success": true, "session_id": id}).to_string());
257 }
258 sm.add_message(crate::session::SessionMessage::from_chat(
259 &crate::llm::ChatMessage::user(msg),
260 ))?;
261 let id = sm.current_session_id.clone().unwrap_or_default();
262 Ok(json!({"success": true, "session_id": id}).to_string())
263 })
264 .await
265 .map_err(|e| AacodeError::Other(e.to_string()))?
266 }
267}
268
269pub struct SwitchSessionTool {
270 pub project_path: PathBuf,
271}
272#[async_trait::async_trait]
273impl Tool for SwitchSessionTool {
274 fn schema(&self) -> ToolSchema {
275 ToolSchema::new(
276 "switch_session",
277 "Switch to a specified conversation session.",
278 vec![ToolParameter::new("session_id", ParamType::String, true, "Session id", &["id"])],
279 )
280 }
281 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
282 let id = args.get("session_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
283 let project_path = self.project_path.clone();
284 tokio::task::spawn_blocking(move || -> Result<String> {
285 let mut sm = SessionManager::new(&project_path);
286 let ok = sm.switch_session(&id).unwrap_or(false);
287 Ok(json!({"success": ok, "session_id": id}).to_string())
288 })
289 .await
290 .map_err(|e| AacodeError::Other(e.to_string()))?
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::llm::types::ToolCall;
298 use crate::llm::ChatMessage;
299 use crate::session::SessionManager;
300
301 fn tmp() -> PathBuf {
302 let d = std::env::temp_dir().join(format!(
303 "aacode_sesstool_{}_{}",
304 std::process::id(),
305 uuid::Uuid::new_v4().simple()
306 ));
307 std::fs::create_dir_all(&d).unwrap();
308 d
309 }
310
311 fn history_tool(path: PathBuf) -> GetConversationHistoryTool {
312 GetConversationHistoryTool {
313 project_path: path,
314 active_session_id: Arc::new(Mutex::new(None)),
315 }
316 }
317
318 #[tokio::test]
319 async fn list_and_stats() {
320 let d = tmp();
321 {
322 let mut sm = SessionManager::new(&d);
323 sm.create_session("task one", None).unwrap();
324 }
325 let cancel = AtomicBool::new(false);
326 let out = ListSessionsTool { project_path: d.clone() }
327 .call(&json!({}), &cancel)
328 .await
329 .unwrap();
330 let v: Value = serde_json::from_str(&out).unwrap();
331 assert!(v["count"].as_u64().unwrap() >= 1);
332
333 let out = GetSessionStatsTool { project_path: d.clone() }
334 .call(&json!({}), &cancel)
335 .await
336 .unwrap();
337 assert!(serde_json::from_str::<Value>(&out).unwrap()["success"] == true);
338 }
339
340 #[tokio::test]
341 async fn delete_session() {
342 let d = tmp();
343 let id = {
344 let mut sm = SessionManager::new(&d);
345 sm.create_session("x", None).unwrap()
346 };
347 let cancel = AtomicBool::new(false);
348 let out = DeleteSessionTool { project_path: d.clone() }
349 .call(&json!({"session_id": id}), &cancel)
350 .await
351 .unwrap();
352 assert_eq!(serde_json::from_str::<Value>(&out).unwrap()["success"], true);
353 }
354
355 #[tokio::test]
356 async fn history_no_sessions() {
357 let d = tmp();
358 let cancel = AtomicBool::new(false);
359 let out = history_tool(d)
360 .call(&json!({}), &cancel)
361 .await
362 .unwrap();
363 assert_eq!(serde_json::from_str::<Value>(&out).unwrap()["success"], false);
364 }
365
366 #[tokio::test]
367 async fn history_includes_tool_calls_and_reasoning() {
368 let d = tmp();
369 let id = {
370 let mut sm = SessionManager::new(&d);
371 sm.create_session("task", None).unwrap();
372 let assistant = ChatMessage::assistant_with_tools(
373 "I will run a command",
374 vec![ToolCall {
375 id: "call_abc".into(),
376 name: "run_shell".into(),
377 arguments: r#"{"command":"ls"}"#.into(),
378 }],
379 );
380 let mut msg = crate::session::SessionMessage::from_chat(&assistant);
381 msg.reasoning_content = Some("I should check the directory".into());
382 sm.add_message(msg).unwrap();
383
384 sm.add_message(crate::session::SessionMessage::from_chat(
385 &ChatMessage::tool_result("call_abc", "file1.txt\nfile2.txt\n"),
386 ))
387 .unwrap();
388 sm.flush().unwrap();
389 sm.current_session_id.clone().unwrap()
390 };
391
392 let cancel = AtomicBool::new(false);
393 let out = history_tool(d.clone())
394 .call(&json!({"session_id": id}), &cancel)
395 .await
396 .unwrap();
397 let v: Value = serde_json::from_str(&out).unwrap();
398 assert_eq!(v["success"], true);
399 let history = v["history"].as_array().unwrap();
400
401 let asst = history
402 .iter()
403 .find(|m| m["role"] == "assistant" && m["tool_calls"].is_array())
404 .expect("assistant with tool_calls should be in history");
405
406 assert_eq!(asst["content"], "I will run a command");
407 assert_eq!(asst["timestamp"].as_str().map(|s| !s.is_empty()), Some(true));
408 let tcs = asst["tool_calls"].as_array().unwrap();
409 assert_eq!(tcs.len(), 1);
410 assert_eq!(tcs[0]["name"], "run_shell");
411 assert_eq!(tcs[0]["arguments"], r#"{"command":"ls"}"#);
412 assert_eq!(asst["reasoning_content"], "I should check the directory");
413
414 let tool = history
415 .iter()
416 .find(|m| m["role"] == "tool")
417 .expect("tool result should be in history");
418 assert_eq!(tool["content"], "file1.txt\nfile2.txt\n");
419 assert_eq!(tool["tool_call_id"], "call_abc");
420 }
421
422 #[tokio::test]
423 async fn history_truncates_long_content() {
424 let d = tmp();
425 let id = {
426 let mut sm = SessionManager::new(&d);
427 sm.create_session("task", None).unwrap();
428 let content = "A".repeat(500);
429 sm.add_message(crate::session::SessionMessage::from_chat(
430 &ChatMessage::assistant(content.clone()),
431 ))
432 .unwrap();
433 sm.flush().unwrap();
434 sm.current_session_id.clone().unwrap()
435 };
436
437 let cancel = AtomicBool::new(false);
438 let out = history_tool(d.clone())
439 .call(&json!({"session_id": id, "max_content_chars": 100}), &cancel)
440 .await
441 .unwrap();
442 let v: Value = serde_json::from_str(&out).unwrap();
443 let history = v["history"].as_array().unwrap();
444 let asst = history.iter().find(|m| m["role"] == "assistant").unwrap();
445 let content = asst["content"].as_str().unwrap();
446 assert!(content.contains("…(500 chars total)"), "should show truncation marker, got: {}", content);
447 assert_eq!(content.chars().take(100).collect::<String>().len(), 100);
448 }
449
450 #[tokio::test]
451 async fn history_truncates_tool_arguments() {
452 let d = tmp();
453 let id = {
454 let mut sm = SessionManager::new(&d);
455 sm.create_session("task", None).unwrap();
456 let long_args = format!(r#"{{"code":"{}"}}"#, "x".repeat(500));
457 let assistant = ChatMessage::assistant_with_tools(
458 "run",
459 vec![ToolCall {
460 id: "c1".into(),
461 name: "execute_python".into(),
462 arguments: long_args.clone(),
463 }],
464 );
465 sm.add_message(crate::session::SessionMessage::from_chat(&assistant))
466 .unwrap();
467 sm.flush().unwrap();
468 sm.current_session_id.clone().unwrap()
469 };
470
471 let cancel = AtomicBool::new(false);
472 let out = history_tool(d.clone())
473 .call(&json!({"session_id": id, "max_content_chars": 100}), &cancel)
474 .await
475 .unwrap();
476 let v: Value = serde_json::from_str(&out).unwrap();
477 let history = v["history"].as_array().unwrap();
478 let asst = history
479 .iter()
480 .find(|m| m["role"] == "assistant" && m["tool_calls"].is_array())
481 .expect("assistant with tool_calls should be in history");
482 let tcs = asst["tool_calls"].as_array().unwrap();
483 let args = tcs[0]["arguments"].as_str().unwrap();
484 assert!(args.contains("…("), "tool_calls arguments should be truncated, got: {}", args);
485 }
486
487 #[tokio::test]
488 async fn history_range_from_and_to() {
489 let d = tmp();
490 let id = {
491 let mut sm = SessionManager::new(&d);
492 sm.create_session("task", None).unwrap();
493 for i in 0..10 {
494 sm.add_message(crate::session::SessionMessage::from_chat(
495 &ChatMessage::user(format!("msg {}", i)),
496 ))
497 .unwrap();
498 }
499 sm.flush().unwrap();
500 sm.current_session_id.clone().unwrap()
501 };
502
503 let cancel = AtomicBool::new(false);
504 let out = history_tool(d.clone())
506 .call(&json!({"session_id": id, "range_from": 2, "range_to": 5}), &cancel)
507 .await
508 .unwrap();
509 let v: Value = serde_json::from_str(&out).unwrap();
510 assert_eq!(v["success"], true);
511 assert_eq!(v["total_messages"], 11); let history = v["history"].as_array().unwrap();
513 assert_eq!(history.len(), 3, "range_from=2, range_to=5 should return 3 messages");
514 assert_eq!(history[0]["content"], "msg 1");
515 assert_eq!(history[2]["content"], "msg 3");
516 }
517
518 #[tokio::test]
519 async fn history_max_content_chars_zero_means_no_truncation() {
520 let d = tmp();
521 let id = {
522 let mut sm = SessionManager::new(&d);
523 sm.create_session("task", None).unwrap();
524 let content = "A".repeat(500);
525 sm.add_message(crate::session::SessionMessage::from_chat(
526 &ChatMessage::assistant(content.clone()),
527 ))
528 .unwrap();
529 sm.flush().unwrap();
530 sm.current_session_id.clone().unwrap()
531 };
532
533 let cancel = AtomicBool::new(false);
534 let out = history_tool(d.clone())
535 .call(&json!({"session_id": id, "max_content_chars": 0}), &cancel)
536 .await
537 .unwrap();
538 let v: Value = serde_json::from_str(&out).unwrap();
539 let history = v["history"].as_array().unwrap();
540 let asst = history.iter().find(|m| m["role"] == "assistant").unwrap();
541 let content = asst["content"].as_str().unwrap();
542 assert_eq!(content.len(), 500, "max_content_chars=0 should mean no truncation, got len {}", content.len());
543 }
544
545 #[tokio::test]
546 async fn new_and_continue_ack() {
547 let d = tmp();
548 let cancel = AtomicBool::new(false);
549 let nt = NewSessionTool { project_path: d.clone() };
550 let out = nt.call(&json!({"task": "test"}), &cancel).await.unwrap();
551 assert!(out.contains("New session created"));
552 let ct = ContinueSessionTool { project_path: d };
553 let out = ct.call(&json!({"message": "followup"}), &cancel).await.unwrap();
554 assert!(out.contains("true"));
555 }
556
557 #[tokio::test]
558 async fn history_uses_active_session_when_no_session_id() {
559 let d = tmp();
560 let active_id = {
561 let mut sm = SessionManager::new(&d);
562 sm.create_session("active session task", None).unwrap();
563 sm.current_session_id.clone().unwrap()
564 };
565
566 let cancel = AtomicBool::new(false);
567 let holder = Arc::new(Mutex::new(Some(active_id.clone())));
568 let tool = GetConversationHistoryTool {
569 project_path: d.clone(),
570 active_session_id: holder,
571 };
572 let out = tool.call(&json!({}), &cancel).await.unwrap();
573 let v: Value = serde_json::from_str(&out).unwrap();
574 assert_eq!(v["success"], true);
575 assert_eq!(v["session_id"], active_id);
576 assert_eq!(v["total_messages"], 1);
577 }
578}