Skip to main content

zeroclaw_tools/
sessions.rs

1//! Session-to-session messaging tools for inter-agent communication.
2//!
3//! Provides six tools:
4//! - `sessions_current` — identify the currently active session
5//! - `sessions_list` — list active sessions with metadata
6//! - `sessions_history` — read message history from a specific session
7//! - `sessions_send` — send a message to a specific session
8//! - `sessions_reset` — clear a session's message history
9//! - `sessions_delete` — permanently delete a session
10
11use 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/// Validate that a session ID is non-empty and contains at least one
22/// alphanumeric character (prevents blank keys after sanitization).
23#[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
137// ── SessionsListTool ────────────────────────────────────────────────
138
139/// Lists active sessions with their channel, last activity time, and message count.
140pub 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            // Extract channel from key (convention: channel__identifier)
193            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
209// ── SessionsHistoryTool ─────────────────────────────────────────────
210
211/// Reads the message history of a specific session by ID.
212pub 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        // Take the last `limit` messages
297        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
318// ── SessionsSendTool ────────────────────────────────────────────────
319
320/// Sends a message to a specific session, enabling inter-agent communication.
321pub 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
449// ── SessionsCurrentTool ────────────────────────────────────────────
450
451/// Returns the session key and metadata for the currently active session.
452/// Reads the session key from the `TOOL_LOOP_SESSION_KEY` task-local,
453/// which is scoped around gateway and channel agent turns.
454pub 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
515// ── SessionResetTool ────────────────────────────────────────────────
516
517/// Resets a session by clearing its message history. The session key
518/// remains valid for new messages. Useful for cleaning up stale
519/// conversations without deleting the session entry itself.
520pub 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
636// ── SessionDeleteTool ──────────────────────────────────────────────
637
638/// Permanently deletes a session and all its messages. The session key
639/// becomes invalid and must be recreated for new conversations.
640pub 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    // ── Session ID validation tests ─────────────────────────────────
897
898    #[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    // ── SessionsListTool tests ──────────────────────────────────────
939
940    #[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    // ── SessionsHistoryTool tests ───────────────────────────────────
987
988    #[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        // Should show only the last message
1025        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    // ── SessionsSendTool tests ──────────────────────────────────────
1063
1064    #[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        // Verify message was appended
1082        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    // ── SessionsCurrentTool tests ──────────────────────────────────
1244
1245    #[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    // ── SessionResetTool tests ─────────────────────────────────────
1310
1311    #[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        // Verify messages are gone
1323        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        // Bob's session should be untouched
1348        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    // ── SessionDeleteTool tests ────────────────────────────────────
1495
1496    #[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        // Verify session is gone
1508        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        // Bob's session should be untouched
1533        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    // ── NoOpDeleteBackend (test helper) ────────────────────────────
1680
1681    /// Delegates everything except delete_session, which uses the trait
1682    /// default (returns Ok(false) without deleting anything).
1683    /// Coupled to SessionBackend's default — if that default changes,
1684    /// this wrapper's behavior changes too.
1685    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}