1use async_trait::async_trait;
12use serde_json::json;
13use std::collections::BTreeSet;
14use std::fmt::Write;
15use std::sync::Arc;
16use zeroclaw_api::tool::{Tool, ToolResult};
17use zeroclaw_config::policy::SecurityPolicy;
18use zeroclaw_config::policy::ToolOperation;
19use zeroclaw_infra::session_backend::SessionBackend;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum SessionValidationError {
25 Empty,
26 NoAlphanumeric,
27}
28
29impl SessionValidationError {
30 fn message(self) -> &'static str {
31 match self {
32 Self::Empty | Self::NoAlphanumeric => {
33 "Invalid 'session_id': must be non-empty and contain at least one alphanumeric character."
34 }
35 }
36 }
37
38 fn into_tool_result(self) -> ToolResult {
39 ToolResult {
40 success: false,
41 output: String::new(),
42 error: Some(self.message().into()),
43 }
44 }
45}
46
47fn validate_session_id(session_id: &str) -> Result<(), SessionValidationError> {
48 let trimmed = session_id.trim();
49 if trimmed.is_empty() {
50 return Err(SessionValidationError::Empty);
51 }
52 if !trimmed.chars().any(|c| c.is_alphanumeric()) {
53 return Err(SessionValidationError::NoAlphanumeric);
54 }
55 Ok(())
56}
57
58fn resolve_existing_session_key(backend: &dyn SessionBackend, session_id: &str) -> Option<String> {
59 let requested = session_id.trim();
60 let sessions = backend.list_sessions();
61 if sessions.iter().any(|key| key == requested) {
62 return Some(requested.to_string());
63 }
64 if !requested.starts_with("gw_") {
65 let gateway_key = format!("gw_{requested}");
66 if sessions.iter().any(|key| key == &gateway_key) {
67 return Some(gateway_key);
68 }
69 }
70 None
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct SessionOwnershipScope {
75 agent_alias: String,
76 channel_ids: BTreeSet<String>,
77}
78
79impl SessionOwnershipScope {
80 pub fn for_agent(agent_alias: impl Into<String>) -> Self {
81 Self {
82 agent_alias: agent_alias.into(),
83 channel_ids: BTreeSet::new(),
84 }
85 }
86
87 pub fn with_channels<I, S>(agent_alias: impl Into<String>, channel_ids: I) -> Self
88 where
89 I: IntoIterator<Item = S>,
90 S: Into<String>,
91 {
92 Self {
93 agent_alias: agent_alias.into(),
94 channel_ids: channel_ids.into_iter().map(Into::into).collect(),
95 }
96 }
97
98 fn authorize(&self, backend: &dyn SessionBackend, session_id: &str) -> Result<String, String> {
99 let Some(session_key) = resolve_existing_session_key(backend, session_id) else {
100 return Ok(session_id.trim().to_string());
101 };
102
103 let Some(metadata) = backend.get_session_metadata(&session_key) else {
104 return Err(format!(
105 "Session '{session_id}' exists but has no ownership metadata; refusing destructive session operation from agent '{}'.",
106 self.agent_alias
107 ));
108 };
109
110 if let Some(owner) = metadata.agent_alias.as_deref() {
111 if owner == self.agent_alias {
112 return Ok(session_key);
113 }
114 return Err(format!(
115 "Session '{session_id}' is owned by agent '{owner}', not '{}'.",
116 self.agent_alias
117 ));
118 }
119
120 if let Some(channel_id) = metadata.channel_id.as_deref() {
121 if self.channel_ids.contains(channel_id) {
122 return Ok(session_key);
123 }
124 return Err(format!(
125 "Session '{session_id}' belongs to channel '{channel_id}', which is not owned by agent '{}'.",
126 self.agent_alias
127 ));
128 }
129
130 Err(format!(
131 "Session '{session_id}' has no agent or channel ownership metadata; refusing destructive session operation from agent '{}'.",
132 self.agent_alias
133 ))
134 }
135}
136
137pub struct SessionsListTool {
141 backend: Arc<dyn SessionBackend>,
142}
143
144impl SessionsListTool {
145 pub fn new(backend: Arc<dyn SessionBackend>) -> Self {
146 Self { backend }
147 }
148}
149
150#[async_trait]
151impl Tool for SessionsListTool {
152 fn name(&self) -> &str {
153 "sessions_list"
154 }
155
156 fn description(&self) -> &str {
157 "List all active conversation sessions with their channel, last activity time, and message count."
158 }
159
160 fn parameters_schema(&self) -> serde_json::Value {
161 json!({
162 "type": "object",
163 "properties": {
164 "limit": {
165 "type": "integer",
166 "description": "Max sessions to return (default: 50)"
167 }
168 }
169 })
170 }
171
172 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
173 #[allow(clippy::cast_possible_truncation)]
174 let limit = args
175 .get("limit")
176 .and_then(serde_json::Value::as_u64)
177 .map_or(50, |v| v as usize);
178
179 let metadata = self.backend.list_sessions_with_metadata();
180
181 if metadata.is_empty() {
182 return Ok(ToolResult {
183 success: true,
184 output: "No active sessions found.".into(),
185 error: None,
186 });
187 }
188
189 let capped: Vec<_> = metadata.into_iter().take(limit).collect();
190 let mut output = format!("Found {} session(s):\n", capped.len());
191 for meta in &capped {
192 let channel = meta.key.split("__").next().unwrap_or(&meta.key);
194 let _ = writeln!(
195 output,
196 "- {}: channel={}, messages={}, last_activity={}",
197 meta.key, channel, meta.message_count, meta.last_activity
198 );
199 }
200
201 Ok(ToolResult {
202 success: true,
203 output,
204 error: None,
205 })
206 }
207}
208
209pub struct SessionsHistoryTool {
213 backend: Arc<dyn SessionBackend>,
214 security: Arc<SecurityPolicy>,
215}
216
217impl SessionsHistoryTool {
218 pub fn new(backend: Arc<dyn SessionBackend>, security: Arc<SecurityPolicy>) -> Self {
219 Self { backend, security }
220 }
221}
222
223#[async_trait]
224impl Tool for SessionsHistoryTool {
225 fn name(&self) -> &str {
226 "sessions_history"
227 }
228
229 fn description(&self) -> &str {
230 "Read the message history of a specific session by its session ID. Returns the last N messages."
231 }
232
233 fn parameters_schema(&self) -> serde_json::Value {
234 json!({
235 "type": "object",
236 "properties": {
237 "session_id": {
238 "type": "string",
239 "description": "The session ID to read history from (e.g. telegram__user123)"
240 },
241 "limit": {
242 "type": "integer",
243 "description": "Max messages to return, from most recent (default: 20)"
244 }
245 },
246 "required": ["session_id"]
247 })
248 }
249
250 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
251 if let Err(error) = self
252 .security
253 .enforce_tool_operation(ToolOperation::Read, "sessions_history")
254 {
255 return Ok(ToolResult {
256 success: false,
257 output: String::new(),
258 error: Some(error),
259 });
260 }
261
262 let session_id = args
263 .get("session_id")
264 .and_then(|v| v.as_str())
265 .ok_or_else(|| {
266 ::zeroclaw_log::record!(
267 WARN,
268 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
269 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
270 .with_attrs(::serde_json::json!({"param": "session_id"})),
271 "sessions: missing session_id parameter"
272 );
273 anyhow::Error::msg("Missing 'session_id' parameter")
274 })?;
275
276 if let Err(error) = validate_session_id(session_id) {
277 return Ok(error.into_tool_result());
278 }
279
280 #[allow(clippy::cast_possible_truncation)]
281 let limit = args
282 .get("limit")
283 .and_then(serde_json::Value::as_u64)
284 .map_or(20, |v| v as usize);
285
286 let messages = self.backend.load(session_id);
287
288 if messages.is_empty() {
289 return Ok(ToolResult {
290 success: true,
291 output: format!("No messages found for session '{session_id}'."),
292 error: None,
293 });
294 }
295
296 let start = messages.len().saturating_sub(limit);
298 let tail = &messages[start..];
299
300 let mut output = format!(
301 "Session '{}': showing {}/{} messages\n",
302 session_id,
303 tail.len(),
304 messages.len()
305 );
306 for msg in tail {
307 let _ = writeln!(output, "[{}] {}", msg.role, msg.content);
308 }
309
310 Ok(ToolResult {
311 success: true,
312 output,
313 error: None,
314 })
315 }
316}
317
318pub struct SessionsSendTool {
322 backend: Arc<dyn SessionBackend>,
323 security: Arc<SecurityPolicy>,
324}
325
326impl SessionsSendTool {
327 pub fn new(backend: Arc<dyn SessionBackend>, security: Arc<SecurityPolicy>) -> Self {
328 Self { backend, security }
329 }
330}
331
332#[async_trait]
333impl Tool for SessionsSendTool {
334 fn name(&self) -> &str {
335 "sessions_send"
336 }
337
338 fn description(&self) -> &str {
339 "Send a message to a specific session by its session ID. The message is appended to the session's conversation history as a 'user' message, enabling inter-agent communication."
340 }
341
342 fn parameters_schema(&self) -> serde_json::Value {
343 json!({
344 "type": "object",
345 "properties": {
346 "session_id": {
347 "type": "string",
348 "description": "The target session ID (e.g. telegram__user123). Gateway dashboard sessions may be addressed by their dashboard ID or by gw_<id>."
349 },
350 "message": {
351 "type": "string",
352 "description": "The message content to send"
353 }
354 },
355 "required": ["session_id", "message"]
356 })
357 }
358
359 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
360 if let Err(error) = self
361 .security
362 .enforce_tool_operation(ToolOperation::Act, "sessions_send")
363 {
364 return Ok(ToolResult {
365 success: false,
366 output: String::new(),
367 error: Some(error),
368 });
369 }
370
371 let session_id = args
372 .get("session_id")
373 .and_then(|v| v.as_str())
374 .ok_or_else(|| {
375 ::zeroclaw_log::record!(
376 WARN,
377 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
378 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
379 .with_attrs(::serde_json::json!({"param": "session_id"})),
380 "sessions: missing session_id parameter"
381 );
382 anyhow::Error::msg("Missing 'session_id' parameter")
383 })?;
384
385 if let Err(error) = validate_session_id(session_id) {
386 return Ok(error.into_tool_result());
387 }
388
389 let message = args
390 .get("message")
391 .and_then(|v| v.as_str())
392 .ok_or_else(|| {
393 ::zeroclaw_log::record!(
394 WARN,
395 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
396 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
397 .with_attrs(::serde_json::json!({"param": "message"})),
398 "sessions: missing message parameter"
399 );
400 anyhow::Error::msg("Missing 'message' parameter")
401 })?;
402
403 if message.trim().is_empty() {
404 return Ok(ToolResult {
405 success: false,
406 output: String::new(),
407 error: Some("Message content must not be empty.".into()),
408 });
409 }
410
411 let Some(target_session_key) =
412 resolve_existing_session_key(self.backend.as_ref(), session_id)
413 else {
414 return Ok(ToolResult {
415 success: false,
416 output: String::new(),
417 error: Some(format!(
418 "Session '{session_id}' not found. Use sessions_list or sessions_current to choose an existing session. Gateway dashboard sessions are stored as 'gw_<session_id>'."
419 )),
420 });
421 };
422
423 let chat_msg = zeroclaw_api::model_provider::ChatMessage::user(message);
424
425 match self.backend.append(&target_session_key, &chat_msg) {
426 Ok(()) => {
427 let output = if target_session_key == session_id.trim() {
428 format!("Message sent to session '{target_session_key}'.")
429 } else {
430 format!(
431 "Message sent to session '{target_session_key}' (requested '{session_id}')."
432 )
433 };
434 Ok(ToolResult {
435 success: true,
436 output,
437 error: None,
438 })
439 }
440 Err(e) => Ok(ToolResult {
441 success: false,
442 output: String::new(),
443 error: Some(format!("Failed to send message: {e}")),
444 }),
445 }
446 }
447}
448
449pub struct SessionsCurrentTool {
455 backend: Arc<dyn SessionBackend>,
456}
457
458impl SessionsCurrentTool {
459 pub fn new(backend: Arc<dyn SessionBackend>) -> Self {
460 Self { backend }
461 }
462}
463
464#[async_trait]
465impl Tool for SessionsCurrentTool {
466 fn name(&self) -> &str {
467 "sessions_current"
468 }
469
470 fn description(&self) -> &str {
471 "Return the session key and metadata for the session this agent is currently running in."
472 }
473
474 fn parameters_schema(&self) -> serde_json::Value {
475 json!({
476 "type": "object",
477 "properties": {}
478 })
479 }
480
481 async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
482 let session_key = zeroclaw_api::TOOL_LOOP_SESSION_KEY
483 .try_with(Clone::clone)
484 .ok()
485 .flatten();
486
487 let Some(key) = session_key else {
488 return Ok(ToolResult {
489 success: false,
490 output: String::new(),
491 error: Some(
492 "No active session context. This tool is only available during a gateway session.".into(),
493 ),
494 });
495 };
496
497 let mut output = format!("Current session: {key}\n");
498 if let Some(meta) = self.backend.get_session_metadata(&key) {
499 if let Some(name) = meta.name.filter(|name| !name.is_empty()) {
500 let _ = writeln!(output, "Name: {name}");
501 }
502 if meta.message_count > 0 {
503 let _ = writeln!(output, "Messages: {}", meta.message_count);
504 }
505 }
506
507 Ok(ToolResult {
508 success: true,
509 output,
510 error: None,
511 })
512 }
513}
514
515pub struct SessionResetTool {
521 backend: Arc<dyn SessionBackend>,
522 security: Arc<SecurityPolicy>,
523 ownership_scope: Option<SessionOwnershipScope>,
524}
525
526impl SessionResetTool {
527 pub fn new(backend: Arc<dyn SessionBackend>, security: Arc<SecurityPolicy>) -> Self {
528 Self {
529 backend,
530 security,
531 ownership_scope: None,
532 }
533 }
534
535 pub fn for_agent(
536 backend: Arc<dyn SessionBackend>,
537 security: Arc<SecurityPolicy>,
538 ownership_scope: SessionOwnershipScope,
539 ) -> Self {
540 Self {
541 backend,
542 security,
543 ownership_scope: Some(ownership_scope),
544 }
545 }
546}
547
548#[async_trait]
549impl Tool for SessionResetTool {
550 fn name(&self) -> &str {
551 "sessions_reset"
552 }
553
554 fn description(&self) -> &str {
555 "Reset a session by clearing all its messages. The session can still receive new messages after reset."
556 }
557
558 fn parameters_schema(&self) -> serde_json::Value {
559 json!({
560 "type": "object",
561 "properties": {
562 "session_id": {
563 "type": "string",
564 "description": "The session ID to reset (e.g. telegram__user123)"
565 }
566 },
567 "required": ["session_id"]
568 })
569 }
570
571 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
572 if let Err(error) = self
573 .security
574 .enforce_tool_operation(ToolOperation::Act, "sessions_reset")
575 {
576 return Ok(ToolResult {
577 success: false,
578 output: String::new(),
579 error: Some(error),
580 });
581 }
582
583 let session_id = args
584 .get("session_id")
585 .and_then(|v| v.as_str())
586 .ok_or_else(|| {
587 ::zeroclaw_log::record!(
588 WARN,
589 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
590 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
591 .with_attrs(::serde_json::json!({"param": "session_id"})),
592 "sessions: missing session_id parameter"
593 );
594 anyhow::Error::msg("Missing 'session_id' parameter")
595 })?;
596
597 if let Err(error) = validate_session_id(session_id) {
598 return Ok(error.into_tool_result());
599 }
600
601 let target_session_key = match &self.ownership_scope {
602 Some(scope) => match scope.authorize(self.backend.as_ref(), session_id) {
603 Ok(key) => key,
604 Err(error) => {
605 return Ok(ToolResult {
606 success: false,
607 output: String::new(),
608 error: Some(error),
609 });
610 }
611 },
612 None => resolve_existing_session_key(self.backend.as_ref(), session_id)
613 .unwrap_or_else(|| session_id.trim().to_string()),
614 };
615
616 match self.backend.clear_messages(&target_session_key) {
617 Ok(0) => Ok(ToolResult {
618 success: true,
619 output: format!("Session '{target_session_key}' is already empty."),
620 error: None,
621 }),
622 Ok(count) => Ok(ToolResult {
623 success: true,
624 output: format!("Session '{target_session_key}' reset ({count} messages cleared)."),
625 error: None,
626 }),
627 Err(e) => Ok(ToolResult {
628 success: false,
629 output: String::new(),
630 error: Some(format!("Failed to reset session: {e}")),
631 }),
632 }
633 }
634}
635
636pub struct SessionDeleteTool {
641 backend: Arc<dyn SessionBackend>,
642 security: Arc<SecurityPolicy>,
643 ownership_scope: Option<SessionOwnershipScope>,
644}
645
646impl SessionDeleteTool {
647 pub fn new(backend: Arc<dyn SessionBackend>, security: Arc<SecurityPolicy>) -> Self {
648 Self {
649 backend,
650 security,
651 ownership_scope: None,
652 }
653 }
654
655 pub fn for_agent(
656 backend: Arc<dyn SessionBackend>,
657 security: Arc<SecurityPolicy>,
658 ownership_scope: SessionOwnershipScope,
659 ) -> Self {
660 Self {
661 backend,
662 security,
663 ownership_scope: Some(ownership_scope),
664 }
665 }
666}
667
668#[async_trait]
669impl Tool for SessionDeleteTool {
670 fn name(&self) -> &str {
671 "sessions_delete"
672 }
673
674 fn description(&self) -> &str {
675 "Permanently delete a session and all its messages. This cannot be undone."
676 }
677
678 fn parameters_schema(&self) -> serde_json::Value {
679 json!({
680 "type": "object",
681 "properties": {
682 "session_id": {
683 "type": "string",
684 "description": "The session ID to delete (e.g. telegram__user123)"
685 }
686 },
687 "required": ["session_id"]
688 })
689 }
690
691 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
692 if let Err(error) = self
693 .security
694 .enforce_tool_operation(ToolOperation::Act, "sessions_delete")
695 {
696 return Ok(ToolResult {
697 success: false,
698 output: String::new(),
699 error: Some(error),
700 });
701 }
702
703 let session_id = args
704 .get("session_id")
705 .and_then(|v| v.as_str())
706 .ok_or_else(|| {
707 ::zeroclaw_log::record!(
708 WARN,
709 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
710 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
711 .with_attrs(::serde_json::json!({"param": "session_id"})),
712 "sessions: missing session_id parameter"
713 );
714 anyhow::Error::msg("Missing 'session_id' parameter")
715 })?;
716
717 if let Err(error) = validate_session_id(session_id) {
718 return Ok(error.into_tool_result());
719 }
720
721 let target_session_key = match &self.ownership_scope {
722 Some(scope) => match scope.authorize(self.backend.as_ref(), session_id) {
723 Ok(key) => key,
724 Err(error) => {
725 return Ok(ToolResult {
726 success: false,
727 output: String::new(),
728 error: Some(error),
729 });
730 }
731 },
732 None => resolve_existing_session_key(self.backend.as_ref(), session_id)
733 .unwrap_or_else(|| session_id.trim().to_string()),
734 };
735
736 let existed = !self.backend.load(&target_session_key).is_empty();
737
738 match self.backend.delete_session(&target_session_key) {
739 Ok(true) => Ok(ToolResult {
740 success: true,
741 output: format!("Session '{target_session_key}' deleted."),
742 error: None,
743 }),
744 Ok(false) if !existed => Ok(ToolResult {
745 success: true,
746 output: format!(
747 "Session '{target_session_key}' not found (may have already been deleted)."
748 ),
749 error: None,
750 }),
751 Ok(false) => Ok(ToolResult {
752 success: false,
753 output: String::new(),
754 error: Some(format!(
755 "Session '{target_session_key}' exists but could not be deleted \
756 — the storage backend may not support this operation."
757 )),
758 }),
759 Err(e) => Ok(ToolResult {
760 success: false,
761 output: String::new(),
762 error: Some(format!("Failed to delete session: {e}")),
763 }),
764 }
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use chrono::Utc;
772 use std::collections::HashMap;
773 use std::sync::Mutex;
774 use tempfile::TempDir;
775 use zeroclaw_api::model_provider::ChatMessage;
776 use zeroclaw_infra::session_backend::SessionMetadata;
777 use zeroclaw_infra::session_store::SessionStore;
778
779 fn test_security() -> Arc<SecurityPolicy> {
780 Arc::new(SecurityPolicy::default())
781 }
782
783 fn test_backend() -> (TempDir, Arc<dyn SessionBackend>) {
784 let tmp = TempDir::new().unwrap();
785 let store = SessionStore::new(tmp.path()).unwrap();
786 (tmp, Arc::new(store))
787 }
788
789 fn seeded_backend() -> (TempDir, Arc<dyn SessionBackend>) {
790 let tmp = TempDir::new().unwrap();
791 let store = SessionStore::new(tmp.path()).unwrap();
792 store
793 .append("telegram__alice", &ChatMessage::user("Hello from Alice"))
794 .unwrap();
795 store
796 .append(
797 "telegram__alice",
798 &ChatMessage::assistant("Hi Alice, how can I help?"),
799 )
800 .unwrap();
801 store
802 .append("discord__bob", &ChatMessage::user("Hey from Bob"))
803 .unwrap();
804 (tmp, Arc::new(store))
805 }
806
807 struct MetadataBackend {
808 inner: Arc<dyn SessionBackend>,
809 metadata: Mutex<HashMap<String, SessionMetadata>>,
810 }
811
812 impl MetadataBackend {
813 fn new(inner: Arc<dyn SessionBackend>, metadata: Vec<SessionMetadata>) -> Self {
814 Self {
815 inner,
816 metadata: Mutex::new(
817 metadata
818 .into_iter()
819 .map(|entry| (entry.key.clone(), entry))
820 .collect(),
821 ),
822 }
823 }
824 }
825
826 impl SessionBackend for MetadataBackend {
827 fn load(&self, key: &str) -> Vec<ChatMessage> {
828 self.inner.load(key)
829 }
830
831 fn append(&self, key: &str, msg: &ChatMessage) -> std::io::Result<()> {
832 self.inner.append(key, msg)
833 }
834
835 fn remove_last(&self, key: &str) -> std::io::Result<bool> {
836 self.inner.remove_last(key)
837 }
838
839 fn list_sessions(&self) -> Vec<String> {
840 self.inner.list_sessions()
841 }
842
843 fn clear_messages(&self, session_key: &str) -> std::io::Result<usize> {
844 self.inner.clear_messages(session_key)
845 }
846
847 fn delete_session(&self, session_key: &str) -> std::io::Result<bool> {
848 let deleted = self.inner.delete_session(session_key)?;
849 if deleted {
850 self.metadata.lock().unwrap().remove(session_key);
851 }
852 Ok(deleted)
853 }
854
855 fn get_session_metadata(&self, session_key: &str) -> Option<SessionMetadata> {
856 self.metadata
857 .lock()
858 .unwrap()
859 .get(session_key)
860 .cloned()
861 .or_else(|| self.inner.get_session_metadata(session_key))
862 }
863 }
864
865 fn session_metadata(
866 key: &str,
867 agent_alias: Option<&str>,
868 channel_id: Option<&str>,
869 message_count: usize,
870 ) -> SessionMetadata {
871 SessionMetadata {
872 key: key.to_string(),
873 name: None,
874 created_at: Utc::now(),
875 last_activity: Utc::now(),
876 message_count,
877 agent_alias: agent_alias.map(str::to_string),
878 channel_id: channel_id.map(str::to_string),
879 room_id: None,
880 sender_id: None,
881 }
882 }
883
884 fn seeded_metadata_backend(
885 metadata: Vec<SessionMetadata>,
886 ) -> (TempDir, Arc<dyn SessionBackend>) {
887 let (tmp, inner) = seeded_backend();
888 (tmp, Arc::new(MetadataBackend::new(inner, metadata)))
889 }
890
891 #[test]
894 fn validate_session_id_rejects_empty() {
895 assert_eq!(validate_session_id(""), Err(SessionValidationError::Empty));
896 }
897
898 #[test]
899 fn validate_session_id_rejects_whitespace_only() {
900 assert_eq!(
901 validate_session_id(" "),
902 Err(SessionValidationError::Empty)
903 );
904 }
905
906 #[test]
907 fn validate_session_id_rejects_non_alphanumeric() {
908 assert_eq!(
909 validate_session_id("///"),
910 Err(SessionValidationError::NoAlphanumeric)
911 );
912 }
913
914 #[test]
915 fn validate_session_id_accepts_valid_id() {
916 assert_eq!(validate_session_id("test_session_id"), Ok(()));
917 }
918
919 #[test]
920 fn validation_error_message_starts_with_invalid() {
921 assert!(
922 SessionValidationError::Empty
923 .message()
924 .starts_with("Invalid")
925 );
926 assert!(
927 SessionValidationError::NoAlphanumeric
928 .message()
929 .starts_with("Invalid")
930 );
931 }
932
933 #[tokio::test]
936 async fn list_empty_sessions() {
937 let (_tmp, backend) = test_backend();
938 let tool = SessionsListTool::new(backend);
939 let result = tool.execute(json!({})).await.unwrap();
940 assert!(result.success);
941 assert!(result.output.contains("No active sessions"));
942 }
943
944 #[tokio::test]
945 async fn list_sessions_shows_all() {
946 let (_tmp, backend) = seeded_backend();
947 let tool = SessionsListTool::new(backend);
948 let result = tool.execute(json!({})).await.unwrap();
949 assert!(result.success);
950 assert!(result.output.contains("2 session(s)"));
951 assert!(result.output.contains("telegram__alice"));
952 assert!(result.output.contains("discord__bob"));
953 }
954
955 #[tokio::test]
956 async fn list_sessions_respects_limit() {
957 let (_tmp, backend) = seeded_backend();
958 let tool = SessionsListTool::new(backend);
959 let result = tool.execute(json!({"limit": 1})).await.unwrap();
960 assert!(result.success);
961 assert!(result.output.contains("1 session(s)"));
962 }
963
964 #[tokio::test]
965 async fn list_sessions_extracts_channel() {
966 let (_tmp, backend) = seeded_backend();
967 let tool = SessionsListTool::new(backend);
968 let result = tool.execute(json!({})).await.unwrap();
969 assert!(result.output.contains("channel=telegram"));
970 assert!(result.output.contains("channel=discord"));
971 }
972
973 #[test]
974 fn list_tool_name_and_schema() {
975 let (_tmp, backend) = test_backend();
976 let tool = SessionsListTool::new(backend);
977 assert_eq!(tool.name(), "sessions_list");
978 assert!(tool.parameters_schema()["properties"]["limit"].is_object());
979 }
980
981 #[tokio::test]
984 async fn history_empty_session() {
985 let (_tmp, backend) = test_backend();
986 let tool = SessionsHistoryTool::new(backend, test_security());
987 let result = tool
988 .execute(json!({"session_id": "nonexistent"}))
989 .await
990 .unwrap();
991 assert!(result.success);
992 assert!(result.output.contains("No messages found"));
993 }
994
995 #[tokio::test]
996 async fn history_returns_messages() {
997 let (_tmp, backend) = seeded_backend();
998 let tool = SessionsHistoryTool::new(backend, test_security());
999 let result = tool
1000 .execute(json!({"session_id": "telegram__alice"}))
1001 .await
1002 .unwrap();
1003 assert!(result.success);
1004 assert!(result.output.contains("showing 2/2 messages"));
1005 assert!(result.output.contains("[user] Hello from Alice"));
1006 assert!(result.output.contains("[assistant] Hi Alice"));
1007 }
1008
1009 #[tokio::test]
1010 async fn history_respects_limit() {
1011 let (_tmp, backend) = seeded_backend();
1012 let tool = SessionsHistoryTool::new(backend, test_security());
1013 let result = tool
1014 .execute(json!({"session_id": "telegram__alice", "limit": 1}))
1015 .await
1016 .unwrap();
1017 assert!(result.success);
1018 assert!(result.output.contains("showing 1/2 messages"));
1019 assert!(result.output.contains("[assistant]"));
1021 assert!(!result.output.contains("[user] Hello from Alice"));
1022 }
1023
1024 #[tokio::test]
1025 async fn history_missing_session_id() {
1026 let (_tmp, backend) = test_backend();
1027 let tool = SessionsHistoryTool::new(backend, test_security());
1028 let result = tool.execute(json!({})).await;
1029 assert!(result.is_err());
1030 assert!(result.unwrap_err().to_string().contains("session_id"));
1031 }
1032
1033 #[tokio::test]
1034 async fn history_rejects_empty_session_id() {
1035 let (_tmp, backend) = test_backend();
1036 let tool = SessionsHistoryTool::new(backend, test_security());
1037 let result = tool.execute(json!({"session_id": " "})).await.unwrap();
1038 assert!(!result.success);
1039 assert!(result.error.is_some());
1040 }
1041
1042 #[test]
1043 fn history_tool_name_and_schema() {
1044 let (_tmp, backend) = test_backend();
1045 let tool = SessionsHistoryTool::new(backend, test_security());
1046 assert_eq!(tool.name(), "sessions_history");
1047 let schema = tool.parameters_schema();
1048 assert!(schema["properties"]["session_id"].is_object());
1049 assert!(
1050 schema["required"]
1051 .as_array()
1052 .unwrap()
1053 .contains(&json!("session_id"))
1054 );
1055 }
1056
1057 #[tokio::test]
1060 async fn send_appends_message_to_existing_session() {
1061 let (_tmp, backend) = test_backend();
1062 backend
1063 .append("telegram__alice", &ChatMessage::user("Hello from Alice"))
1064 .unwrap();
1065 let tool = SessionsSendTool::new(backend.clone(), test_security());
1066 let result = tool
1067 .execute(json!({
1068 "session_id": "telegram__alice",
1069 "message": "Hello from another agent"
1070 }))
1071 .await
1072 .unwrap();
1073 assert!(result.success);
1074 assert!(result.output.contains("Message sent"));
1075
1076 let messages = backend.load("telegram__alice");
1078 assert_eq!(messages.len(), 2);
1079 assert_eq!(messages[1].role, "user");
1080 assert_eq!(messages[1].content, "Hello from another agent");
1081 }
1082
1083 #[tokio::test]
1084 async fn send_to_existing_session() {
1085 let (_tmp, backend) = seeded_backend();
1086 let tool = SessionsSendTool::new(backend.clone(), test_security());
1087 let result = tool
1088 .execute(json!({
1089 "session_id": "telegram__alice",
1090 "message": "Inter-agent message"
1091 }))
1092 .await
1093 .unwrap();
1094 assert!(result.success);
1095
1096 let messages = backend.load("telegram__alice");
1097 assert_eq!(messages.len(), 3);
1098 assert_eq!(messages[2].content, "Inter-agent message");
1099 }
1100
1101 #[tokio::test]
1102 async fn send_to_gateway_session_accepts_dashboard_session_id() {
1103 let (_tmp, backend) = test_backend();
1104 backend
1105 .append(
1106 "gw_operator-1",
1107 &ChatMessage::assistant("Existing dashboard message"),
1108 )
1109 .unwrap();
1110 let tool = SessionsSendTool::new(backend.clone(), test_security());
1111
1112 let result = tool
1113 .execute(json!({
1114 "session_id": "operator-1",
1115 "message": "Wake up"
1116 }))
1117 .await
1118 .unwrap();
1119
1120 assert!(result.success);
1121 assert!(result.output.contains("gw_operator-1"));
1122
1123 let gateway_messages = backend.load("gw_operator-1");
1124 assert_eq!(gateway_messages.len(), 2);
1125 assert_eq!(gateway_messages[1].role, "user");
1126 assert_eq!(gateway_messages[1].content, "Wake up");
1127 assert!(backend.load("operator-1").is_empty());
1128 }
1129
1130 #[tokio::test]
1131 async fn send_rejects_unknown_session() {
1132 let (_tmp, backend) = test_backend();
1133 let tool = SessionsSendTool::new(backend.clone(), test_security());
1134
1135 let result = tool
1136 .execute(json!({
1137 "session_id": "operator-1",
1138 "message": "Wake up"
1139 }))
1140 .await
1141 .unwrap();
1142
1143 assert!(!result.success);
1144 assert!(
1145 result
1146 .error
1147 .as_deref()
1148 .unwrap_or_default()
1149 .contains("not found")
1150 );
1151 assert!(backend.load("operator-1").is_empty());
1152 assert!(backend.load("gw_operator-1").is_empty());
1153 }
1154
1155 #[tokio::test]
1156 async fn send_rejects_empty_message() {
1157 let (_tmp, backend) = test_backend();
1158 let tool = SessionsSendTool::new(backend, test_security());
1159 let result = tool
1160 .execute(json!({
1161 "session_id": "telegram__alice",
1162 "message": " "
1163 }))
1164 .await
1165 .unwrap();
1166 assert!(!result.success);
1167 assert!(result.error.unwrap().contains("empty"));
1168 }
1169
1170 #[tokio::test]
1171 async fn send_rejects_empty_session_id() {
1172 let (_tmp, backend) = test_backend();
1173 let tool = SessionsSendTool::new(backend, test_security());
1174 let result = tool
1175 .execute(json!({
1176 "session_id": "",
1177 "message": "hello"
1178 }))
1179 .await
1180 .unwrap();
1181 assert!(!result.success);
1182 assert!(result.error.is_some());
1183 }
1184
1185 #[tokio::test]
1186 async fn send_rejects_non_alphanumeric_session_id() {
1187 let (_tmp, backend) = test_backend();
1188 let tool = SessionsSendTool::new(backend, test_security());
1189 let result = tool
1190 .execute(json!({
1191 "session_id": "///",
1192 "message": "hello"
1193 }))
1194 .await
1195 .unwrap();
1196 assert!(!result.success);
1197 assert!(result.error.is_some());
1198 }
1199
1200 #[tokio::test]
1201 async fn send_missing_session_id() {
1202 let (_tmp, backend) = test_backend();
1203 let tool = SessionsSendTool::new(backend, test_security());
1204 let result = tool.execute(json!({"message": "hi"})).await;
1205 assert!(result.is_err());
1206 assert!(result.unwrap_err().to_string().contains("session_id"));
1207 }
1208
1209 #[tokio::test]
1210 async fn send_missing_message() {
1211 let (_tmp, backend) = test_backend();
1212 let tool = SessionsSendTool::new(backend, test_security());
1213 let result = tool.execute(json!({"session_id": "telegram__alice"})).await;
1214 assert!(result.is_err());
1215 assert!(result.unwrap_err().to_string().contains("message"));
1216 }
1217
1218 #[test]
1219 fn send_tool_name_and_schema() {
1220 let (_tmp, backend) = test_backend();
1221 let tool = SessionsSendTool::new(backend, test_security());
1222 assert_eq!(tool.name(), "sessions_send");
1223 let schema = tool.parameters_schema();
1224 assert!(
1225 schema["required"]
1226 .as_array()
1227 .unwrap()
1228 .contains(&json!("session_id"))
1229 );
1230 assert!(
1231 schema["required"]
1232 .as_array()
1233 .unwrap()
1234 .contains(&json!("message"))
1235 );
1236 }
1237
1238 #[tokio::test]
1241 async fn sessions_current_returns_key_when_scoped() {
1242 let (tmp, backend) = test_backend();
1243 let _ = tmp;
1244 backend
1245 .append("gw_test-123", &ChatMessage::user("hello"))
1246 .unwrap();
1247
1248 let tool = SessionsCurrentTool::new(backend);
1249 let result = zeroclaw_api::TOOL_LOOP_SESSION_KEY
1250 .scope(Some("gw_test-123".into()), tool.execute(json!({})))
1251 .await
1252 .unwrap();
1253
1254 assert!(result.success);
1255 assert!(result.output.contains("gw_test-123"));
1256 assert!(result.output.contains("Messages: 1"));
1257 }
1258
1259 #[tokio::test]
1260 async fn sessions_current_fails_without_scope() {
1261 let (_tmp, backend) = test_backend();
1262 let tool = SessionsCurrentTool::new(backend);
1263
1264 let result = tool.execute(json!({})).await.unwrap();
1265 assert!(!result.success);
1266 assert!(result.error.unwrap().contains("No active session context"));
1267 }
1268
1269 #[tokio::test]
1270 async fn sessions_current_includes_name() {
1271 let tmp = TempDir::new().unwrap();
1272 let sqlite = zeroclaw_infra::session_sqlite::SqliteSessionBackend::new(tmp.path()).unwrap();
1273 let backend: Arc<dyn SessionBackend> = Arc::new(sqlite);
1274 backend
1275 .append("gw_named", &ChatMessage::user("hi"))
1276 .unwrap();
1277 backend.set_session_name("gw_named", "My Chat").unwrap();
1278
1279 let tool = SessionsCurrentTool::new(backend);
1280 let result = zeroclaw_api::TOOL_LOOP_SESSION_KEY
1281 .scope(Some("gw_named".into()), tool.execute(json!({})))
1282 .await
1283 .unwrap();
1284
1285 assert!(result.success);
1286 assert!(result.output.contains("My Chat"));
1287 }
1288
1289 #[tokio::test]
1290 async fn sessions_current_unknown_key_still_succeeds() {
1291 let (_tmp, backend) = test_backend();
1292 let tool = SessionsCurrentTool::new(backend);
1293
1294 let result = zeroclaw_api::TOOL_LOOP_SESSION_KEY
1295 .scope(Some("gw_unknown".into()), tool.execute(json!({})))
1296 .await
1297 .unwrap();
1298
1299 assert!(result.success);
1300 assert!(result.output.contains("gw_unknown"));
1301 assert!(!result.output.contains("Messages:"));
1302 }
1303
1304 #[tokio::test]
1307 async fn reset_clears_messages() {
1308 let (_tmp, backend) = seeded_backend();
1309 let tool = SessionResetTool::new(backend.clone(), test_security());
1310 let result = tool
1311 .execute(json!({"session_id": "telegram__alice"}))
1312 .await
1313 .unwrap();
1314 assert!(result.success);
1315 assert!(result.output.contains("2 messages cleared"));
1316
1317 let messages = backend.load("telegram__alice");
1319 assert!(messages.is_empty());
1320 }
1321
1322 #[tokio::test]
1323 async fn reset_empty_session_is_noop() {
1324 let (_tmp, backend) = test_backend();
1325 let tool = SessionResetTool::new(backend, test_security());
1326 let result = tool
1327 .execute(json!({"session_id": "nonexistent"}))
1328 .await
1329 .unwrap();
1330 assert!(result.success);
1331 assert!(result.output.contains("already empty"));
1332 }
1333
1334 #[tokio::test]
1335 async fn reset_does_not_affect_other_sessions() {
1336 let (_tmp, backend) = seeded_backend();
1337 let tool = SessionResetTool::new(backend.clone(), test_security());
1338 tool.execute(json!({"session_id": "telegram__alice"}))
1339 .await
1340 .unwrap();
1341
1342 let bob_msgs = backend.load("discord__bob");
1344 assert_eq!(bob_msgs.len(), 1);
1345 }
1346
1347 #[tokio::test]
1348 async fn reset_scoped_allows_own_agent_session() {
1349 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1350 "telegram__alice",
1351 Some("rowan"),
1352 None,
1353 2,
1354 )]);
1355 let tool = SessionResetTool::for_agent(
1356 backend.clone(),
1357 test_security(),
1358 SessionOwnershipScope::for_agent("rowan"),
1359 );
1360
1361 let result = tool
1362 .execute(json!({"session_id": "telegram__alice"}))
1363 .await
1364 .unwrap();
1365
1366 assert!(result.success);
1367 assert!(result.output.contains("2 messages cleared"));
1368 assert!(backend.load("telegram__alice").is_empty());
1369 }
1370
1371 #[tokio::test]
1372 async fn reset_scoped_denies_other_agent_session() {
1373 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1374 "telegram__alice",
1375 Some("sable"),
1376 None,
1377 2,
1378 )]);
1379 let tool = SessionResetTool::for_agent(
1380 backend.clone(),
1381 test_security(),
1382 SessionOwnershipScope::for_agent("rowan"),
1383 );
1384
1385 let result = tool
1386 .execute(json!({"session_id": "telegram__alice"}))
1387 .await
1388 .unwrap();
1389
1390 assert!(!result.success);
1391 assert!(result.error.unwrap().contains("owned by agent 'sable'"));
1392 assert_eq!(backend.load("telegram__alice").len(), 2);
1393 }
1394
1395 #[tokio::test]
1396 async fn reset_scoped_allows_owned_channel_session() {
1397 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1398 "telegram__alice",
1399 None,
1400 Some("telegram.default"),
1401 2,
1402 )]);
1403 let tool = SessionResetTool::for_agent(
1404 backend.clone(),
1405 test_security(),
1406 SessionOwnershipScope::with_channels("rowan", ["telegram.default"]),
1407 );
1408
1409 let result = tool
1410 .execute(json!({"session_id": "telegram__alice"}))
1411 .await
1412 .unwrap();
1413
1414 assert!(result.success);
1415 assert!(backend.load("telegram__alice").is_empty());
1416 }
1417
1418 #[tokio::test]
1419 async fn reset_scoped_denies_unowned_channel_session() {
1420 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1421 "telegram__alice",
1422 None,
1423 Some("telegram.default"),
1424 2,
1425 )]);
1426 let tool = SessionResetTool::for_agent(
1427 backend.clone(),
1428 test_security(),
1429 SessionOwnershipScope::with_channels("rowan", ["discord.default"]),
1430 );
1431
1432 let result = tool
1433 .execute(json!({"session_id": "telegram__alice"}))
1434 .await
1435 .unwrap();
1436
1437 assert!(!result.success);
1438 assert!(result.error.unwrap().contains("not owned by agent 'rowan'"));
1439 assert_eq!(backend.load("telegram__alice").len(), 2);
1440 }
1441
1442 #[tokio::test]
1443 async fn reset_scoped_denies_legacy_unattributed_session() {
1444 let (_tmp, backend) = seeded_backend();
1445 let tool = SessionResetTool::for_agent(
1446 backend.clone(),
1447 test_security(),
1448 SessionOwnershipScope::for_agent("rowan"),
1449 );
1450
1451 let result = tool
1452 .execute(json!({"session_id": "telegram__alice"}))
1453 .await
1454 .unwrap();
1455
1456 assert!(!result.success);
1457 assert!(
1458 result
1459 .error
1460 .unwrap()
1461 .contains("no agent or channel ownership metadata")
1462 );
1463 assert_eq!(backend.load("telegram__alice").len(), 2);
1464 }
1465
1466 #[tokio::test]
1467 async fn reset_rejects_empty_session_id() {
1468 let (_tmp, backend) = test_backend();
1469 let tool = SessionResetTool::new(backend, test_security());
1470 let result = tool.execute(json!({"session_id": ""})).await.unwrap();
1471 assert!(!result.success);
1472 assert!(result.error.is_some());
1473 }
1474
1475 #[test]
1476 fn reset_tool_name_and_schema() {
1477 let (_tmp, backend) = test_backend();
1478 let tool = SessionResetTool::new(backend, test_security());
1479 assert_eq!(tool.name(), "sessions_reset");
1480 let schema = tool.parameters_schema();
1481 assert!(
1482 schema["required"]
1483 .as_array()
1484 .unwrap()
1485 .contains(&json!("session_id"))
1486 );
1487 }
1488
1489 #[tokio::test]
1492 async fn delete_removes_session() {
1493 let (_tmp, backend) = seeded_backend();
1494 let tool = SessionDeleteTool::new(backend.clone(), test_security());
1495 let result = tool
1496 .execute(json!({"session_id": "telegram__alice"}))
1497 .await
1498 .unwrap();
1499 assert!(result.success);
1500 assert!(result.output.contains("deleted"));
1501
1502 let messages = backend.load("telegram__alice");
1504 assert!(messages.is_empty());
1505 }
1506
1507 #[tokio::test]
1508 async fn delete_nonexistent_session_succeeds() {
1509 let (_tmp, backend) = test_backend();
1510 let tool = SessionDeleteTool::new(backend, test_security());
1511 let result = tool
1512 .execute(json!({"session_id": "nonexistent"}))
1513 .await
1514 .unwrap();
1515 assert!(result.success);
1516 assert!(result.output.contains("not found"));
1517 }
1518
1519 #[tokio::test]
1520 async fn delete_does_not_affect_other_sessions() {
1521 let (_tmp, backend) = seeded_backend();
1522 let tool = SessionDeleteTool::new(backend.clone(), test_security());
1523 tool.execute(json!({"session_id": "telegram__alice"}))
1524 .await
1525 .unwrap();
1526
1527 let bob_msgs = backend.load("discord__bob");
1529 assert_eq!(bob_msgs.len(), 1);
1530 }
1531
1532 #[tokio::test]
1533 async fn delete_scoped_allows_own_agent_session() {
1534 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1535 "telegram__alice",
1536 Some("rowan"),
1537 None,
1538 2,
1539 )]);
1540 let tool = SessionDeleteTool::for_agent(
1541 backend.clone(),
1542 test_security(),
1543 SessionOwnershipScope::for_agent("rowan"),
1544 );
1545
1546 let result = tool
1547 .execute(json!({"session_id": "telegram__alice"}))
1548 .await
1549 .unwrap();
1550
1551 assert!(result.success);
1552 assert!(result.output.contains("deleted"));
1553 assert!(backend.load("telegram__alice").is_empty());
1554 }
1555
1556 #[tokio::test]
1557 async fn delete_scoped_denies_other_agent_session() {
1558 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1559 "telegram__alice",
1560 Some("sable"),
1561 None,
1562 2,
1563 )]);
1564 let tool = SessionDeleteTool::for_agent(
1565 backend.clone(),
1566 test_security(),
1567 SessionOwnershipScope::for_agent("rowan"),
1568 );
1569
1570 let result = tool
1571 .execute(json!({"session_id": "telegram__alice"}))
1572 .await
1573 .unwrap();
1574
1575 assert!(!result.success);
1576 assert!(result.error.unwrap().contains("owned by agent 'sable'"));
1577 assert_eq!(backend.load("telegram__alice").len(), 2);
1578 }
1579
1580 #[tokio::test]
1581 async fn delete_scoped_allows_owned_channel_session() {
1582 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1583 "telegram__alice",
1584 None,
1585 Some("telegram.default"),
1586 2,
1587 )]);
1588 let tool = SessionDeleteTool::for_agent(
1589 backend.clone(),
1590 test_security(),
1591 SessionOwnershipScope::with_channels("rowan", ["telegram.default"]),
1592 );
1593
1594 let result = tool
1595 .execute(json!({"session_id": "telegram__alice"}))
1596 .await
1597 .unwrap();
1598
1599 assert!(result.success);
1600 assert!(backend.load("telegram__alice").is_empty());
1601 }
1602
1603 #[tokio::test]
1604 async fn delete_scoped_denies_unowned_channel_session() {
1605 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1606 "telegram__alice",
1607 None,
1608 Some("telegram.default"),
1609 2,
1610 )]);
1611 let tool = SessionDeleteTool::for_agent(
1612 backend.clone(),
1613 test_security(),
1614 SessionOwnershipScope::with_channels("rowan", ["discord.default"]),
1615 );
1616
1617 let result = tool
1618 .execute(json!({"session_id": "telegram__alice"}))
1619 .await
1620 .unwrap();
1621
1622 assert!(!result.success);
1623 assert!(result.error.unwrap().contains("not owned by agent 'rowan'"));
1624 assert_eq!(backend.load("telegram__alice").len(), 2);
1625 }
1626
1627 #[tokio::test]
1628 async fn delete_scoped_denies_legacy_unattributed_session() {
1629 let (_tmp, backend) = seeded_backend();
1630 let tool = SessionDeleteTool::for_agent(
1631 backend.clone(),
1632 test_security(),
1633 SessionOwnershipScope::for_agent("rowan"),
1634 );
1635
1636 let result = tool
1637 .execute(json!({"session_id": "telegram__alice"}))
1638 .await
1639 .unwrap();
1640
1641 assert!(!result.success);
1642 assert!(
1643 result
1644 .error
1645 .unwrap()
1646 .contains("no agent or channel ownership metadata")
1647 );
1648 assert_eq!(backend.load("telegram__alice").len(), 2);
1649 }
1650
1651 #[tokio::test]
1652 async fn delete_rejects_empty_session_id() {
1653 let (_tmp, backend) = test_backend();
1654 let tool = SessionDeleteTool::new(backend, test_security());
1655 let result = tool.execute(json!({"session_id": " "})).await.unwrap();
1656 assert!(!result.success);
1657 assert!(result.error.is_some());
1658 }
1659
1660 #[test]
1661 fn delete_tool_name_and_schema() {
1662 let (_tmp, backend) = test_backend();
1663 let tool = SessionDeleteTool::new(backend, test_security());
1664 assert_eq!(tool.name(), "sessions_delete");
1665 let schema = tool.parameters_schema();
1666 assert!(
1667 schema["required"]
1668 .as_array()
1669 .unwrap()
1670 .contains(&json!("session_id"))
1671 );
1672 }
1673
1674 struct NoOpDeleteBackend(Arc<dyn SessionBackend>);
1681
1682 impl SessionBackend for NoOpDeleteBackend {
1683 fn load(&self, key: &str) -> Vec<ChatMessage> {
1684 self.0.load(key)
1685 }
1686 fn append(&self, key: &str, msg: &ChatMessage) -> std::io::Result<()> {
1687 self.0.append(key, msg)
1688 }
1689 fn remove_last(&self, key: &str) -> std::io::Result<bool> {
1690 self.0.remove_last(key)
1691 }
1692 fn list_sessions(&self) -> Vec<String> {
1693 self.0.list_sessions()
1694 }
1695 }
1696
1697 #[tokio::test]
1698 async fn delete_detects_noop_backend() {
1699 let (_tmp, inner) = seeded_backend();
1700 let backend: Arc<dyn SessionBackend> = Arc::new(NoOpDeleteBackend(inner));
1701 let tool = SessionDeleteTool::new(backend, test_security());
1702 let result = tool
1703 .execute(json!({"session_id": "telegram__alice"}))
1704 .await
1705 .unwrap();
1706 assert!(!result.success);
1707 assert!(result.error.unwrap().contains("could not be deleted"));
1708 }
1709}