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 fn session_exists(&self, session_key: &str) -> bool {
865 self.metadata.lock().unwrap().contains_key(session_key)
866 || self.inner.session_exists(session_key)
867 }
868 }
869
870 fn session_metadata(
871 key: &str,
872 agent_alias: Option<&str>,
873 channel_id: Option<&str>,
874 message_count: usize,
875 ) -> SessionMetadata {
876 SessionMetadata {
877 key: key.to_string(),
878 name: None,
879 created_at: Utc::now(),
880 last_activity: Utc::now(),
881 message_count,
882 agent_alias: agent_alias.map(str::to_string),
883 channel_id: channel_id.map(str::to_string),
884 room_id: None,
885 sender_id: None,
886 }
887 }
888
889 fn seeded_metadata_backend(
890 metadata: Vec<SessionMetadata>,
891 ) -> (TempDir, Arc<dyn SessionBackend>) {
892 let (tmp, inner) = seeded_backend();
893 (tmp, Arc::new(MetadataBackend::new(inner, metadata)))
894 }
895
896 #[test]
899 fn validate_session_id_rejects_empty() {
900 assert_eq!(validate_session_id(""), Err(SessionValidationError::Empty));
901 }
902
903 #[test]
904 fn validate_session_id_rejects_whitespace_only() {
905 assert_eq!(
906 validate_session_id(" "),
907 Err(SessionValidationError::Empty)
908 );
909 }
910
911 #[test]
912 fn validate_session_id_rejects_non_alphanumeric() {
913 assert_eq!(
914 validate_session_id("///"),
915 Err(SessionValidationError::NoAlphanumeric)
916 );
917 }
918
919 #[test]
920 fn validate_session_id_accepts_valid_id() {
921 assert_eq!(validate_session_id("test_session_id"), Ok(()));
922 }
923
924 #[test]
925 fn validation_error_message_starts_with_invalid() {
926 assert!(
927 SessionValidationError::Empty
928 .message()
929 .starts_with("Invalid")
930 );
931 assert!(
932 SessionValidationError::NoAlphanumeric
933 .message()
934 .starts_with("Invalid")
935 );
936 }
937
938 #[tokio::test]
941 async fn list_empty_sessions() {
942 let (_tmp, backend) = test_backend();
943 let tool = SessionsListTool::new(backend);
944 let result = tool.execute(json!({})).await.unwrap();
945 assert!(result.success);
946 assert!(result.output.contains("No active sessions"));
947 }
948
949 #[tokio::test]
950 async fn list_sessions_shows_all() {
951 let (_tmp, backend) = seeded_backend();
952 let tool = SessionsListTool::new(backend);
953 let result = tool.execute(json!({})).await.unwrap();
954 assert!(result.success);
955 assert!(result.output.contains("2 session(s)"));
956 assert!(result.output.contains("telegram__alice"));
957 assert!(result.output.contains("discord__bob"));
958 }
959
960 #[tokio::test]
961 async fn list_sessions_respects_limit() {
962 let (_tmp, backend) = seeded_backend();
963 let tool = SessionsListTool::new(backend);
964 let result = tool.execute(json!({"limit": 1})).await.unwrap();
965 assert!(result.success);
966 assert!(result.output.contains("1 session(s)"));
967 }
968
969 #[tokio::test]
970 async fn list_sessions_extracts_channel() {
971 let (_tmp, backend) = seeded_backend();
972 let tool = SessionsListTool::new(backend);
973 let result = tool.execute(json!({})).await.unwrap();
974 assert!(result.output.contains("channel=telegram"));
975 assert!(result.output.contains("channel=discord"));
976 }
977
978 #[test]
979 fn list_tool_name_and_schema() {
980 let (_tmp, backend) = test_backend();
981 let tool = SessionsListTool::new(backend);
982 assert_eq!(tool.name(), "sessions_list");
983 assert!(tool.parameters_schema()["properties"]["limit"].is_object());
984 }
985
986 #[tokio::test]
989 async fn history_empty_session() {
990 let (_tmp, backend) = test_backend();
991 let tool = SessionsHistoryTool::new(backend, test_security());
992 let result = tool
993 .execute(json!({"session_id": "nonexistent"}))
994 .await
995 .unwrap();
996 assert!(result.success);
997 assert!(result.output.contains("No messages found"));
998 }
999
1000 #[tokio::test]
1001 async fn history_returns_messages() {
1002 let (_tmp, backend) = seeded_backend();
1003 let tool = SessionsHistoryTool::new(backend, test_security());
1004 let result = tool
1005 .execute(json!({"session_id": "telegram__alice"}))
1006 .await
1007 .unwrap();
1008 assert!(result.success);
1009 assert!(result.output.contains("showing 2/2 messages"));
1010 assert!(result.output.contains("[user] Hello from Alice"));
1011 assert!(result.output.contains("[assistant] Hi Alice"));
1012 }
1013
1014 #[tokio::test]
1015 async fn history_respects_limit() {
1016 let (_tmp, backend) = seeded_backend();
1017 let tool = SessionsHistoryTool::new(backend, test_security());
1018 let result = tool
1019 .execute(json!({"session_id": "telegram__alice", "limit": 1}))
1020 .await
1021 .unwrap();
1022 assert!(result.success);
1023 assert!(result.output.contains("showing 1/2 messages"));
1024 assert!(result.output.contains("[assistant]"));
1026 assert!(!result.output.contains("[user] Hello from Alice"));
1027 }
1028
1029 #[tokio::test]
1030 async fn history_missing_session_id() {
1031 let (_tmp, backend) = test_backend();
1032 let tool = SessionsHistoryTool::new(backend, test_security());
1033 let result = tool.execute(json!({})).await;
1034 assert!(result.is_err());
1035 assert!(result.unwrap_err().to_string().contains("session_id"));
1036 }
1037
1038 #[tokio::test]
1039 async fn history_rejects_empty_session_id() {
1040 let (_tmp, backend) = test_backend();
1041 let tool = SessionsHistoryTool::new(backend, test_security());
1042 let result = tool.execute(json!({"session_id": " "})).await.unwrap();
1043 assert!(!result.success);
1044 assert!(result.error.is_some());
1045 }
1046
1047 #[test]
1048 fn history_tool_name_and_schema() {
1049 let (_tmp, backend) = test_backend();
1050 let tool = SessionsHistoryTool::new(backend, test_security());
1051 assert_eq!(tool.name(), "sessions_history");
1052 let schema = tool.parameters_schema();
1053 assert!(schema["properties"]["session_id"].is_object());
1054 assert!(
1055 schema["required"]
1056 .as_array()
1057 .unwrap()
1058 .contains(&json!("session_id"))
1059 );
1060 }
1061
1062 #[tokio::test]
1065 async fn send_appends_message_to_existing_session() {
1066 let (_tmp, backend) = test_backend();
1067 backend
1068 .append("telegram__alice", &ChatMessage::user("Hello from Alice"))
1069 .unwrap();
1070 let tool = SessionsSendTool::new(backend.clone(), test_security());
1071 let result = tool
1072 .execute(json!({
1073 "session_id": "telegram__alice",
1074 "message": "Hello from another agent"
1075 }))
1076 .await
1077 .unwrap();
1078 assert!(result.success);
1079 assert!(result.output.contains("Message sent"));
1080
1081 let messages = backend.load("telegram__alice");
1083 assert_eq!(messages.len(), 2);
1084 assert_eq!(messages[1].role, "user");
1085 assert_eq!(messages[1].content, "Hello from another agent");
1086 }
1087
1088 #[tokio::test]
1089 async fn send_to_existing_session() {
1090 let (_tmp, backend) = seeded_backend();
1091 let tool = SessionsSendTool::new(backend.clone(), test_security());
1092 let result = tool
1093 .execute(json!({
1094 "session_id": "telegram__alice",
1095 "message": "Inter-agent message"
1096 }))
1097 .await
1098 .unwrap();
1099 assert!(result.success);
1100
1101 let messages = backend.load("telegram__alice");
1102 assert_eq!(messages.len(), 3);
1103 assert_eq!(messages[2].content, "Inter-agent message");
1104 }
1105
1106 #[tokio::test]
1107 async fn send_to_gateway_session_accepts_dashboard_session_id() {
1108 let (_tmp, backend) = test_backend();
1109 backend
1110 .append(
1111 "gw_operator-1",
1112 &ChatMessage::assistant("Existing dashboard message"),
1113 )
1114 .unwrap();
1115 let tool = SessionsSendTool::new(backend.clone(), test_security());
1116
1117 let result = tool
1118 .execute(json!({
1119 "session_id": "operator-1",
1120 "message": "Wake up"
1121 }))
1122 .await
1123 .unwrap();
1124
1125 assert!(result.success);
1126 assert!(result.output.contains("gw_operator-1"));
1127
1128 let gateway_messages = backend.load("gw_operator-1");
1129 assert_eq!(gateway_messages.len(), 2);
1130 assert_eq!(gateway_messages[1].role, "user");
1131 assert_eq!(gateway_messages[1].content, "Wake up");
1132 assert!(backend.load("operator-1").is_empty());
1133 }
1134
1135 #[tokio::test]
1136 async fn send_rejects_unknown_session() {
1137 let (_tmp, backend) = test_backend();
1138 let tool = SessionsSendTool::new(backend.clone(), test_security());
1139
1140 let result = tool
1141 .execute(json!({
1142 "session_id": "operator-1",
1143 "message": "Wake up"
1144 }))
1145 .await
1146 .unwrap();
1147
1148 assert!(!result.success);
1149 assert!(
1150 result
1151 .error
1152 .as_deref()
1153 .unwrap_or_default()
1154 .contains("not found")
1155 );
1156 assert!(backend.load("operator-1").is_empty());
1157 assert!(backend.load("gw_operator-1").is_empty());
1158 }
1159
1160 #[tokio::test]
1161 async fn send_rejects_empty_message() {
1162 let (_tmp, backend) = test_backend();
1163 let tool = SessionsSendTool::new(backend, test_security());
1164 let result = tool
1165 .execute(json!({
1166 "session_id": "telegram__alice",
1167 "message": " "
1168 }))
1169 .await
1170 .unwrap();
1171 assert!(!result.success);
1172 assert!(result.error.unwrap().contains("empty"));
1173 }
1174
1175 #[tokio::test]
1176 async fn send_rejects_empty_session_id() {
1177 let (_tmp, backend) = test_backend();
1178 let tool = SessionsSendTool::new(backend, test_security());
1179 let result = tool
1180 .execute(json!({
1181 "session_id": "",
1182 "message": "hello"
1183 }))
1184 .await
1185 .unwrap();
1186 assert!(!result.success);
1187 assert!(result.error.is_some());
1188 }
1189
1190 #[tokio::test]
1191 async fn send_rejects_non_alphanumeric_session_id() {
1192 let (_tmp, backend) = test_backend();
1193 let tool = SessionsSendTool::new(backend, test_security());
1194 let result = tool
1195 .execute(json!({
1196 "session_id": "///",
1197 "message": "hello"
1198 }))
1199 .await
1200 .unwrap();
1201 assert!(!result.success);
1202 assert!(result.error.is_some());
1203 }
1204
1205 #[tokio::test]
1206 async fn send_missing_session_id() {
1207 let (_tmp, backend) = test_backend();
1208 let tool = SessionsSendTool::new(backend, test_security());
1209 let result = tool.execute(json!({"message": "hi"})).await;
1210 assert!(result.is_err());
1211 assert!(result.unwrap_err().to_string().contains("session_id"));
1212 }
1213
1214 #[tokio::test]
1215 async fn send_missing_message() {
1216 let (_tmp, backend) = test_backend();
1217 let tool = SessionsSendTool::new(backend, test_security());
1218 let result = tool.execute(json!({"session_id": "telegram__alice"})).await;
1219 assert!(result.is_err());
1220 assert!(result.unwrap_err().to_string().contains("message"));
1221 }
1222
1223 #[test]
1224 fn send_tool_name_and_schema() {
1225 let (_tmp, backend) = test_backend();
1226 let tool = SessionsSendTool::new(backend, test_security());
1227 assert_eq!(tool.name(), "sessions_send");
1228 let schema = tool.parameters_schema();
1229 assert!(
1230 schema["required"]
1231 .as_array()
1232 .unwrap()
1233 .contains(&json!("session_id"))
1234 );
1235 assert!(
1236 schema["required"]
1237 .as_array()
1238 .unwrap()
1239 .contains(&json!("message"))
1240 );
1241 }
1242
1243 #[tokio::test]
1246 async fn sessions_current_returns_key_when_scoped() {
1247 let (tmp, backend) = test_backend();
1248 let _ = tmp;
1249 backend
1250 .append("gw_test-123", &ChatMessage::user("hello"))
1251 .unwrap();
1252
1253 let tool = SessionsCurrentTool::new(backend);
1254 let result = zeroclaw_api::TOOL_LOOP_SESSION_KEY
1255 .scope(Some("gw_test-123".into()), tool.execute(json!({})))
1256 .await
1257 .unwrap();
1258
1259 assert!(result.success);
1260 assert!(result.output.contains("gw_test-123"));
1261 assert!(result.output.contains("Messages: 1"));
1262 }
1263
1264 #[tokio::test]
1265 async fn sessions_current_fails_without_scope() {
1266 let (_tmp, backend) = test_backend();
1267 let tool = SessionsCurrentTool::new(backend);
1268
1269 let result = tool.execute(json!({})).await.unwrap();
1270 assert!(!result.success);
1271 assert!(result.error.unwrap().contains("No active session context"));
1272 }
1273
1274 #[tokio::test]
1275 async fn sessions_current_includes_name() {
1276 let tmp = TempDir::new().unwrap();
1277 let sqlite = zeroclaw_infra::session_sqlite::SqliteSessionBackend::new(tmp.path()).unwrap();
1278 let backend: Arc<dyn SessionBackend> = Arc::new(sqlite);
1279 backend
1280 .append("gw_named", &ChatMessage::user("hi"))
1281 .unwrap();
1282 backend.set_session_name("gw_named", "My Chat").unwrap();
1283
1284 let tool = SessionsCurrentTool::new(backend);
1285 let result = zeroclaw_api::TOOL_LOOP_SESSION_KEY
1286 .scope(Some("gw_named".into()), tool.execute(json!({})))
1287 .await
1288 .unwrap();
1289
1290 assert!(result.success);
1291 assert!(result.output.contains("My Chat"));
1292 }
1293
1294 #[tokio::test]
1295 async fn sessions_current_unknown_key_still_succeeds() {
1296 let (_tmp, backend) = test_backend();
1297 let tool = SessionsCurrentTool::new(backend);
1298
1299 let result = zeroclaw_api::TOOL_LOOP_SESSION_KEY
1300 .scope(Some("gw_unknown".into()), tool.execute(json!({})))
1301 .await
1302 .unwrap();
1303
1304 assert!(result.success);
1305 assert!(result.output.contains("gw_unknown"));
1306 assert!(!result.output.contains("Messages:"));
1307 }
1308
1309 #[tokio::test]
1312 async fn reset_clears_messages() {
1313 let (_tmp, backend) = seeded_backend();
1314 let tool = SessionResetTool::new(backend.clone(), test_security());
1315 let result = tool
1316 .execute(json!({"session_id": "telegram__alice"}))
1317 .await
1318 .unwrap();
1319 assert!(result.success);
1320 assert!(result.output.contains("2 messages cleared"));
1321
1322 let messages = backend.load("telegram__alice");
1324 assert!(messages.is_empty());
1325 }
1326
1327 #[tokio::test]
1328 async fn reset_empty_session_is_noop() {
1329 let (_tmp, backend) = test_backend();
1330 let tool = SessionResetTool::new(backend, test_security());
1331 let result = tool
1332 .execute(json!({"session_id": "nonexistent"}))
1333 .await
1334 .unwrap();
1335 assert!(result.success);
1336 assert!(result.output.contains("already empty"));
1337 }
1338
1339 #[tokio::test]
1340 async fn reset_does_not_affect_other_sessions() {
1341 let (_tmp, backend) = seeded_backend();
1342 let tool = SessionResetTool::new(backend.clone(), test_security());
1343 tool.execute(json!({"session_id": "telegram__alice"}))
1344 .await
1345 .unwrap();
1346
1347 let bob_msgs = backend.load("discord__bob");
1349 assert_eq!(bob_msgs.len(), 1);
1350 }
1351
1352 #[tokio::test]
1353 async fn reset_scoped_allows_own_agent_session() {
1354 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1355 "telegram__alice",
1356 Some("rowan"),
1357 None,
1358 2,
1359 )]);
1360 let tool = SessionResetTool::for_agent(
1361 backend.clone(),
1362 test_security(),
1363 SessionOwnershipScope::for_agent("rowan"),
1364 );
1365
1366 let result = tool
1367 .execute(json!({"session_id": "telegram__alice"}))
1368 .await
1369 .unwrap();
1370
1371 assert!(result.success);
1372 assert!(result.output.contains("2 messages cleared"));
1373 assert!(backend.load("telegram__alice").is_empty());
1374 }
1375
1376 #[tokio::test]
1377 async fn reset_scoped_denies_other_agent_session() {
1378 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1379 "telegram__alice",
1380 Some("sable"),
1381 None,
1382 2,
1383 )]);
1384 let tool = SessionResetTool::for_agent(
1385 backend.clone(),
1386 test_security(),
1387 SessionOwnershipScope::for_agent("rowan"),
1388 );
1389
1390 let result = tool
1391 .execute(json!({"session_id": "telegram__alice"}))
1392 .await
1393 .unwrap();
1394
1395 assert!(!result.success);
1396 assert!(result.error.unwrap().contains("owned by agent 'sable'"));
1397 assert_eq!(backend.load("telegram__alice").len(), 2);
1398 }
1399
1400 #[tokio::test]
1401 async fn reset_scoped_allows_owned_channel_session() {
1402 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1403 "telegram__alice",
1404 None,
1405 Some("telegram.default"),
1406 2,
1407 )]);
1408 let tool = SessionResetTool::for_agent(
1409 backend.clone(),
1410 test_security(),
1411 SessionOwnershipScope::with_channels("rowan", ["telegram.default"]),
1412 );
1413
1414 let result = tool
1415 .execute(json!({"session_id": "telegram__alice"}))
1416 .await
1417 .unwrap();
1418
1419 assert!(result.success);
1420 assert!(backend.load("telegram__alice").is_empty());
1421 }
1422
1423 #[tokio::test]
1424 async fn reset_scoped_denies_unowned_channel_session() {
1425 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1426 "telegram__alice",
1427 None,
1428 Some("telegram.default"),
1429 2,
1430 )]);
1431 let tool = SessionResetTool::for_agent(
1432 backend.clone(),
1433 test_security(),
1434 SessionOwnershipScope::with_channels("rowan", ["discord.default"]),
1435 );
1436
1437 let result = tool
1438 .execute(json!({"session_id": "telegram__alice"}))
1439 .await
1440 .unwrap();
1441
1442 assert!(!result.success);
1443 assert!(result.error.unwrap().contains("not owned by agent 'rowan'"));
1444 assert_eq!(backend.load("telegram__alice").len(), 2);
1445 }
1446
1447 #[tokio::test]
1448 async fn reset_scoped_denies_legacy_unattributed_session() {
1449 let (_tmp, backend) = seeded_backend();
1450 let tool = SessionResetTool::for_agent(
1451 backend.clone(),
1452 test_security(),
1453 SessionOwnershipScope::for_agent("rowan"),
1454 );
1455
1456 let result = tool
1457 .execute(json!({"session_id": "telegram__alice"}))
1458 .await
1459 .unwrap();
1460
1461 assert!(!result.success);
1462 assert!(
1463 result
1464 .error
1465 .unwrap()
1466 .contains("no agent or channel ownership metadata")
1467 );
1468 assert_eq!(backend.load("telegram__alice").len(), 2);
1469 }
1470
1471 #[tokio::test]
1472 async fn reset_rejects_empty_session_id() {
1473 let (_tmp, backend) = test_backend();
1474 let tool = SessionResetTool::new(backend, test_security());
1475 let result = tool.execute(json!({"session_id": ""})).await.unwrap();
1476 assert!(!result.success);
1477 assert!(result.error.is_some());
1478 }
1479
1480 #[test]
1481 fn reset_tool_name_and_schema() {
1482 let (_tmp, backend) = test_backend();
1483 let tool = SessionResetTool::new(backend, test_security());
1484 assert_eq!(tool.name(), "sessions_reset");
1485 let schema = tool.parameters_schema();
1486 assert!(
1487 schema["required"]
1488 .as_array()
1489 .unwrap()
1490 .contains(&json!("session_id"))
1491 );
1492 }
1493
1494 #[tokio::test]
1497 async fn delete_removes_session() {
1498 let (_tmp, backend) = seeded_backend();
1499 let tool = SessionDeleteTool::new(backend.clone(), test_security());
1500 let result = tool
1501 .execute(json!({"session_id": "telegram__alice"}))
1502 .await
1503 .unwrap();
1504 assert!(result.success);
1505 assert!(result.output.contains("deleted"));
1506
1507 let messages = backend.load("telegram__alice");
1509 assert!(messages.is_empty());
1510 }
1511
1512 #[tokio::test]
1513 async fn delete_nonexistent_session_succeeds() {
1514 let (_tmp, backend) = test_backend();
1515 let tool = SessionDeleteTool::new(backend, test_security());
1516 let result = tool
1517 .execute(json!({"session_id": "nonexistent"}))
1518 .await
1519 .unwrap();
1520 assert!(result.success);
1521 assert!(result.output.contains("not found"));
1522 }
1523
1524 #[tokio::test]
1525 async fn delete_does_not_affect_other_sessions() {
1526 let (_tmp, backend) = seeded_backend();
1527 let tool = SessionDeleteTool::new(backend.clone(), test_security());
1528 tool.execute(json!({"session_id": "telegram__alice"}))
1529 .await
1530 .unwrap();
1531
1532 let bob_msgs = backend.load("discord__bob");
1534 assert_eq!(bob_msgs.len(), 1);
1535 }
1536
1537 #[tokio::test]
1538 async fn delete_scoped_allows_own_agent_session() {
1539 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1540 "telegram__alice",
1541 Some("rowan"),
1542 None,
1543 2,
1544 )]);
1545 let tool = SessionDeleteTool::for_agent(
1546 backend.clone(),
1547 test_security(),
1548 SessionOwnershipScope::for_agent("rowan"),
1549 );
1550
1551 let result = tool
1552 .execute(json!({"session_id": "telegram__alice"}))
1553 .await
1554 .unwrap();
1555
1556 assert!(result.success);
1557 assert!(result.output.contains("deleted"));
1558 assert!(backend.load("telegram__alice").is_empty());
1559 }
1560
1561 #[tokio::test]
1562 async fn delete_scoped_denies_other_agent_session() {
1563 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1564 "telegram__alice",
1565 Some("sable"),
1566 None,
1567 2,
1568 )]);
1569 let tool = SessionDeleteTool::for_agent(
1570 backend.clone(),
1571 test_security(),
1572 SessionOwnershipScope::for_agent("rowan"),
1573 );
1574
1575 let result = tool
1576 .execute(json!({"session_id": "telegram__alice"}))
1577 .await
1578 .unwrap();
1579
1580 assert!(!result.success);
1581 assert!(result.error.unwrap().contains("owned by agent 'sable'"));
1582 assert_eq!(backend.load("telegram__alice").len(), 2);
1583 }
1584
1585 #[tokio::test]
1586 async fn delete_scoped_allows_owned_channel_session() {
1587 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1588 "telegram__alice",
1589 None,
1590 Some("telegram.default"),
1591 2,
1592 )]);
1593 let tool = SessionDeleteTool::for_agent(
1594 backend.clone(),
1595 test_security(),
1596 SessionOwnershipScope::with_channels("rowan", ["telegram.default"]),
1597 );
1598
1599 let result = tool
1600 .execute(json!({"session_id": "telegram__alice"}))
1601 .await
1602 .unwrap();
1603
1604 assert!(result.success);
1605 assert!(backend.load("telegram__alice").is_empty());
1606 }
1607
1608 #[tokio::test]
1609 async fn delete_scoped_denies_unowned_channel_session() {
1610 let (_tmp, backend) = seeded_metadata_backend(vec![session_metadata(
1611 "telegram__alice",
1612 None,
1613 Some("telegram.default"),
1614 2,
1615 )]);
1616 let tool = SessionDeleteTool::for_agent(
1617 backend.clone(),
1618 test_security(),
1619 SessionOwnershipScope::with_channels("rowan", ["discord.default"]),
1620 );
1621
1622 let result = tool
1623 .execute(json!({"session_id": "telegram__alice"}))
1624 .await
1625 .unwrap();
1626
1627 assert!(!result.success);
1628 assert!(result.error.unwrap().contains("not owned by agent 'rowan'"));
1629 assert_eq!(backend.load("telegram__alice").len(), 2);
1630 }
1631
1632 #[tokio::test]
1633 async fn delete_scoped_denies_legacy_unattributed_session() {
1634 let (_tmp, backend) = seeded_backend();
1635 let tool = SessionDeleteTool::for_agent(
1636 backend.clone(),
1637 test_security(),
1638 SessionOwnershipScope::for_agent("rowan"),
1639 );
1640
1641 let result = tool
1642 .execute(json!({"session_id": "telegram__alice"}))
1643 .await
1644 .unwrap();
1645
1646 assert!(!result.success);
1647 assert!(
1648 result
1649 .error
1650 .unwrap()
1651 .contains("no agent or channel ownership metadata")
1652 );
1653 assert_eq!(backend.load("telegram__alice").len(), 2);
1654 }
1655
1656 #[tokio::test]
1657 async fn delete_rejects_empty_session_id() {
1658 let (_tmp, backend) = test_backend();
1659 let tool = SessionDeleteTool::new(backend, test_security());
1660 let result = tool.execute(json!({"session_id": " "})).await.unwrap();
1661 assert!(!result.success);
1662 assert!(result.error.is_some());
1663 }
1664
1665 #[test]
1666 fn delete_tool_name_and_schema() {
1667 let (_tmp, backend) = test_backend();
1668 let tool = SessionDeleteTool::new(backend, test_security());
1669 assert_eq!(tool.name(), "sessions_delete");
1670 let schema = tool.parameters_schema();
1671 assert!(
1672 schema["required"]
1673 .as_array()
1674 .unwrap()
1675 .contains(&json!("session_id"))
1676 );
1677 }
1678
1679 struct NoOpDeleteBackend(Arc<dyn SessionBackend>);
1686
1687 impl SessionBackend for NoOpDeleteBackend {
1688 fn load(&self, key: &str) -> Vec<ChatMessage> {
1689 self.0.load(key)
1690 }
1691 fn append(&self, key: &str, msg: &ChatMessage) -> std::io::Result<()> {
1692 self.0.append(key, msg)
1693 }
1694 fn remove_last(&self, key: &str) -> std::io::Result<bool> {
1695 self.0.remove_last(key)
1696 }
1697 fn list_sessions(&self) -> Vec<String> {
1698 self.0.list_sessions()
1699 }
1700 }
1701
1702 #[tokio::test]
1703 async fn delete_detects_noop_backend() {
1704 let (_tmp, inner) = seeded_backend();
1705 let backend: Arc<dyn SessionBackend> = Arc::new(NoOpDeleteBackend(inner));
1706 let tool = SessionDeleteTool::new(backend, test_security());
1707 let result = tool
1708 .execute(json!({"session_id": "telegram__alice"}))
1709 .await
1710 .unwrap();
1711 assert!(!result.success);
1712 assert!(result.error.unwrap().contains("could not be deleted"));
1713 }
1714}