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