Skip to main content

oxi_agent/tools/browse/
browse_session_tool.rs

1//! Interactive browser session tool — persistent tab across tool calls.
2//!
3//! Manages a single browser tab that persists between `execute()` calls,
4//! enabling multi-step workflows where the agent can reason between actions.
5//! Uses `TabGuard` for RAII cleanup on drop.
6
7use super::config::BrowseConfig;
8use super::engine::{BrowserEngine, BrowserError};
9use super::helpers;
10use super::tab_guard::TabGuard;
11use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError};
12use async_trait::async_trait;
13use serde_json::{json, Value};
14use std::sync::Arc;
15use std::time::Instant;
16use tokio::sync::{oneshot, Mutex};
17
18/// Interactive browser session with a persistent tab across calls.
19///
20/// Open a session, perform multiple operations (goto, click, fill, etc.),
21/// read page content between steps, then close when done. The tab retains
22/// cookies, localStorage, and DOM state between actions.
23pub struct BrowseSessionTool {
24    engine: Arc<dyn BrowserEngine>,
25    tab: Arc<Mutex<Option<TabGuard>>>,
26    config: BrowseConfig,
27    last_action: Arc<Mutex<Option<Instant>>>,
28}
29
30impl BrowseSessionTool {
31    /// Create with the given engine and default config.
32    pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
33        Self {
34            engine,
35            tab: Arc::new(Mutex::new(None)),
36            config: BrowseConfig::default(),
37            last_action: Arc::new(Mutex::new(None)),
38        }
39    }
40
41    /// Create with custom configuration.
42    pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
43        Self {
44            engine,
45            tab: Arc::new(Mutex::new(None)),
46            config,
47            last_action: Arc::new(Mutex::new(None)),
48        }
49    }
50
51    /// Update the last-action timestamp to now.
52    async fn touch(&self) {
53        *self.last_action.lock().await = Some(Instant::now());
54    }
55
56    /// Check idle timeout. Returns Ok if session is still valid or
57    /// if idle timeout is disabled (0). Auto-closes stale sessions.
58    async fn check_idle_timeout(&self) -> Result<(), ToolError> {
59        if self.config.session_idle_timeout_secs == 0 {
60            return Ok(());
61        }
62        let elapsed = {
63            let last = self.last_action.lock().await;
64            match *last {
65                Some(instant) => instant.elapsed().as_secs(),
66                None => return Ok(()), // No action yet, session is fresh
67            }
68        };
69        if elapsed >= self.config.session_idle_timeout_secs {
70            // Auto-close stale session
71            let mut slot = self.tab.lock().await;
72            if let Some(guard) = slot.take() {
73                tracing::warn!(
74                    elapsed_secs = elapsed,
75                    timeout_secs = self.config.session_idle_timeout_secs,
76                    "browse_session: auto-closing stale session"
77                );
78                guard.close().await;
79            }
80            return Err(format!(
81                "Session timed out after {}s of inactivity",
82                elapsed
83            ));
84        }
85        Ok(())
86    }
87}
88
89#[async_trait]
90impl AgentTool for BrowseSessionTool {
91    fn name(&self) -> &str {
92        "browse_session"
93    }
94
95    fn label(&self) -> &str {
96        "Browser Session"
97    }
98
99    fn description(&self) -> &str {
100        "Interactive browser session with a persistent tab across calls. \
101         Open a session, perform multiple operations, then close when done. \
102         The tab retains cookies, localStorage, and DOM state between actions. \
103         Use for multi-step interactions like form filling, login flows, and \
104         SPA exploration where reasoning is needed between steps."
105    }
106
107    fn parameters_schema(&self) -> Value {
108        json!({
109            "type": "object",
110            "properties": {
111                "action": {
112                    "type": "string",
113                    "enum": [
114                        "open",
115                        "goto",
116                        "back",
117                        "forward",
118                        "reload",
119                        "click",
120                        "fill",
121                        "type",
122                        "clear",
123                        "press",
124                        "select",
125                        "check",
126                        "uncheck",
127                        "scroll",
128                        "scroll_into_view",
129                        "hover",
130                        "double_click",
131                        "right_click",
132                        "drag",
133                        "upload_file",
134                        "wait_for",
135                        "content",
136                        "query_all",
137                        "extract_links",
138                        "evaluate",
139                        "evaluate_await",
140                        "get_value",
141                        "screenshot",
142                        "close"
143                    ],
144                    "description": "Session action to perform"
145                },
146                "url": {
147                    "type": "string",
148                    "description": "URL to navigate to (goto action)"
149                },
150                "selector": {
151                    "type": "string",
152                    "description": "CSS selector (click, fill, type, clear, select, check, uncheck, wait_for, query_all, extract_links)"
153                },
154                "value": {
155                    "type": "string",
156                    "description": "Value to fill/type/select (fill, type, select actions)"
157                },
158                "combo": {
159                    "type": "string",
160                    "description": "Key combo (press action, e.g. 'Enter', 'Control+a')"
161                },
162                "pixels": {
163                    "type": "integer",
164                    "description": "Scroll distance in pixels (scroll action, positive = down)"
165                },
166                "javascript": {
167                    "type": "string",
168                    "description": "JS expression to evaluate (evaluate, evaluate_await actions)"
169                },
170                "format": {
171                    "type": "string",
172                    "enum": ["markdown", "html", "text", "links"],
173                    "default": "markdown",
174                    "description": "Output format for content action"
175                },
176                "timeout_ms": {
177                    "type": "integer",
178                    "default": 10000,
179                    "description": "Timeout in ms (wait_for action)"
180                },
181                "from_selector": {
182                    "type": "string",
183                    "description": "Source CSS selector (drag action)"
184                },
185                "to_selector": {
186                    "type": "string",
187                    "description": "Target CSS selector (drag action)"
188                },
189                "file_path": {
190                    "type": "string",
191                    "description": "Local file path to upload (upload_file action)"
192                },
193                "width": {
194                    "type": "integer",
195                    "default": 800,
196                    "description": "Viewport width for screenshot (default: 800)"
197                }
198            },
199            "required": ["action"]
200        })
201    }
202
203    #[allow(clippy::too_many_lines)]
204    async fn execute(
205        &self,
206        _tool_call_id: &str,
207        params: Value,
208        _signal: Option<oneshot::Receiver<()>>,
209        _ctx: &ToolContext,
210    ) -> Result<AgentToolResult, ToolError> {
211        let action = params["action"]
212            .as_str()
213            .ok_or_else(|| "Missing required parameter: action".to_string())?;
214
215        let url = params["url"].as_str();
216        let selector = params["selector"].as_str();
217        let value = params["value"].as_str();
218        let combo = params["combo"].as_str();
219        let pixels = params["pixels"].as_u64().unwrap_or(300);
220        let javascript = params["javascript"].as_str();
221        let format = params["format"].as_str().unwrap_or("markdown");
222        let timeout_ms = params["timeout_ms"]
223            .as_u64()
224            .unwrap_or(self.config.default_wait_timeout_ms);
225        let width = params["width"]
226            .as_u64()
227            .unwrap_or(self.config.screenshot_width as u64) as u32;
228        let from_selector = params["from_selector"].as_str();
229        let to_selector = params["to_selector"].as_str();
230        let file_path = params["file_path"].as_str();
231
232        tracing::info!(action = %action, "browse_session action");
233
234        self.touch().await;
235
236        match action {
237            // ── Lifecycle ────────────────────────────────────────────
238            "open" => {
239                let mut slot = self.tab.lock().await;
240                // If a session is already open, close it first
241                if let Some(old_guard) = slot.take() {
242                    tracing::warn!("browse_session: closing previous session on re-open");
243                    old_guard.close().await;
244                }
245                let raw_tab = self
246                    .engine
247                    .new_tab()
248                    .await
249                    .map_err(|e| format!("Failed to open browser tab: {}", e))?;
250                let guard = TabGuard::new(raw_tab);
251                *slot = Some(guard);
252                Ok(json_ok())
253            }
254
255            "close" => {
256                let mut slot = self.tab.lock().await;
257                match slot.take() {
258                    Some(guard) => {
259                        guard.close().await;
260                        Ok(json_ok())
261                    }
262                    None => Ok(json_error("no active session to close")),
263                }
264            }
265
266            // ── Navigation ──────────────────────────────────────────
267            "goto" => {
268                self.check_idle_timeout().await?;
269                let url = url.ok_or_else(|| "Missing required parameter: url".to_string())?;
270                let slot = self.tab.lock().await;
271                let tab = require_tab(&slot)?;
272                let page = tab.goto(url).await.map_err(browser_err)?;
273                Ok(AgentToolResult::success(json_str(&json!({
274                    "status": "ok",
275                    "url": page.url,
276                    "title": page.title,
277                    "status_code": page.status,
278                }))))
279            }
280
281            "back" => {
282                self.check_idle_timeout().await?;
283                let slot = self.tab.lock().await;
284                let tab = require_tab(&slot)?;
285                let _ = tab.evaluate("history.back()").await;
286                let page = tab.content().await.map_err(browser_err)?;
287                Ok(AgentToolResult::success(json_str(&json!({
288                    "status": "ok",
289                    "url": page.url,
290                    "title": page.title,
291                }))))
292            }
293
294            "forward" => {
295                self.check_idle_timeout().await?;
296                let slot = self.tab.lock().await;
297                let tab = require_tab(&slot)?;
298                let _ = tab.evaluate("history.forward()").await;
299                let page = tab.content().await.map_err(browser_err)?;
300                Ok(AgentToolResult::success(json_str(&json!({
301                    "status": "ok",
302                    "url": page.url,
303                    "title": page.title,
304                }))))
305            }
306
307            "reload" => {
308                self.check_idle_timeout().await?;
309                let slot = self.tab.lock().await;
310                let tab = require_tab(&slot)?;
311                let _ = tab.evaluate("location.reload()").await;
312                let page = tab.content().await.map_err(browser_err)?;
313                Ok(AgentToolResult::success(json_str(&json!({
314                    "status": "ok",
315                    "url": page.url,
316                    "title": page.title,
317                }))))
318            }
319
320            // ── DOM interaction ─────────────────────────────────────
321            "click" => {
322                self.check_idle_timeout().await?;
323                let sel =
324                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
325                let slot = self.tab.lock().await;
326                let tab = require_tab(&slot)?;
327                tab.click(sel).await.map_err(browser_err)?;
328                Ok(json_ok())
329            }
330
331            "fill" => {
332                self.check_idle_timeout().await?;
333                let sel =
334                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
335                let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
336                let slot = self.tab.lock().await;
337                let tab = require_tab(&slot)?;
338                tab.fill(sel, val).await.map_err(browser_err)?;
339                Ok(json_ok())
340            }
341
342            "type" => {
343                self.check_idle_timeout().await?;
344                let sel =
345                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
346                let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
347                let slot = self.tab.lock().await;
348                let tab = require_tab(&slot)?;
349                tab.type_(sel, val).await.map_err(browser_err)?;
350                Ok(json_ok())
351            }
352
353            "clear" => {
354                self.check_idle_timeout().await?;
355                let sel =
356                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
357                let slot = self.tab.lock().await;
358                let tab = require_tab(&slot)?;
359                tab.clear(sel).await.map_err(browser_err)?;
360                Ok(json_ok())
361            }
362
363            "press" => {
364                self.check_idle_timeout().await?;
365                let c = combo.ok_or_else(|| "Missing required parameter: combo".to_string())?;
366                let slot = self.tab.lock().await;
367                let tab = require_tab(&slot)?;
368                tab.press(c).await.map_err(browser_err)?;
369                Ok(json_ok())
370            }
371
372            "select" => {
373                self.check_idle_timeout().await?;
374                let sel =
375                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
376                let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
377                let slot = self.tab.lock().await;
378                let tab = require_tab(&slot)?;
379                tab.select_option(sel, val).await.map_err(browser_err)?;
380                Ok(json_ok())
381            }
382
383            "check" => {
384                self.check_idle_timeout().await?;
385                let sel =
386                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
387                let slot = self.tab.lock().await;
388                let tab = require_tab(&slot)?;
389                tab.check(sel).await.map_err(browser_err)?;
390                Ok(json_ok())
391            }
392
393            "uncheck" => {
394                self.check_idle_timeout().await?;
395                let sel =
396                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
397                let slot = self.tab.lock().await;
398                let tab = require_tab(&slot)?;
399                tab.uncheck(sel).await.map_err(browser_err)?;
400                Ok(json_ok())
401            }
402
403            "scroll" => {
404                self.check_idle_timeout().await?;
405                let slot = self.tab.lock().await;
406                let tab = require_tab(&slot)?;
407                tab.scroll(0.0, pixels as f64).await.map_err(browser_err)?;
408                Ok(json_ok())
409            }
410
411            // ── Wait ────────────────────────────────────────────────
412            "wait_for" => {
413                self.check_idle_timeout().await?;
414                let sel =
415                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
416                let slot = self.tab.lock().await;
417                let tab = require_tab(&slot)?;
418                tab.wait_for(sel, timeout_ms).await.map_err(browser_err)?;
419                Ok(json_ok())
420            }
421
422            // ── Read ────────────────────────────────────────────────
423            "content" => {
424                self.check_idle_timeout().await?;
425                let slot = self.tab.lock().await;
426                let tab = require_tab(&slot)?;
427                let page = tab.content().await.map_err(browser_err)?;
428
429                let content = match format {
430                    "html" => {
431                        if let Some(sel) = selector {
432                            tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
433                        } else {
434                            page.html.clone()
435                        }
436                    }
437                    "links" => {
438                        let links = if let Some(sel) = selector {
439                            let js = helpers::js_links_within(sel);
440                            let value = tab.evaluate(&js).await.map_err(browser_err)?;
441                            helpers::parse_link_values(value)
442                        } else {
443                            helpers::extract_links(tab)
444                                .await
445                                .map_err(|e: ToolError| e)?
446                        };
447                        helpers::format_links(&links)
448                    }
449                    "text" => {
450                        if let Some(sel) = selector {
451                            tab.query_all(sel).await.map_err(browser_err)?.join("\n")
452                        } else {
453                            page.markdown.clone()
454                        }
455                    }
456                    _ => {
457                        // "markdown" (default)
458                        if let Some(sel) = selector {
459                            tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
460                        } else {
461                            page.markdown.clone()
462                        }
463                    }
464                };
465
466                Ok(AgentToolResult::success(json_str(&json!({
467                    "status": "ok",
468                    "url": page.url,
469                    "title": page.title,
470                    "content": content,
471                }))))
472            }
473
474            "query_all" => {
475                self.check_idle_timeout().await?;
476                let sel =
477                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
478                let slot = self.tab.lock().await;
479                let tab = require_tab(&slot)?;
480                let results = tab.query_all(sel).await.map_err(browser_err)?;
481                Ok(AgentToolResult::success(json_str(&json!({
482                    "status": "ok",
483                    "results": results,
484                }))))
485            }
486
487            "extract_links" => {
488                self.check_idle_timeout().await?;
489                let slot = self.tab.lock().await;
490                let tab = require_tab(&slot)?;
491
492                let links = if let Some(sel) = selector {
493                    let js = helpers::js_links_within(sel);
494                    let value = tab.evaluate(&js).await.map_err(browser_err)?;
495                    helpers::parse_link_values(value)
496                } else {
497                    helpers::extract_links(tab)
498                        .await
499                        .map_err(|e: ToolError| e)?
500                };
501
502                let json_links: Vec<Value> = links
503                    .iter()
504                    .map(|(text, href)| json!({ "text": text, "href": href }))
505                    .collect();
506
507                Ok(AgentToolResult::success(json_str(&json!({
508                    "status": "ok",
509                    "links": json_links,
510                }))))
511            }
512
513            // ── Evaluate ────────────────────────────────────────────
514            "evaluate" => {
515                self.check_idle_timeout().await?;
516                let js = javascript
517                    .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
518                let slot = self.tab.lock().await;
519                let tab = require_tab(&slot)?;
520                let result_val = tab.evaluate(js).await.map_err(browser_err)?;
521                Ok(AgentToolResult::success(json_str(&json!({
522                    "status": "ok",
523                    "result": result_val,
524                }))))
525            }
526
527            "evaluate_await" => {
528                self.check_idle_timeout().await?;
529                let js = javascript
530                    .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
531                let slot = self.tab.lock().await;
532                let tab = require_tab(&slot)?;
533                let result_val = tab.evaluate_await(js).await.map_err(browser_err)?;
534                Ok(AgentToolResult::success(json_str(&json!({
535                    "status": "ok",
536                    "result": result_val,
537                }))))
538            }
539
540            // ── Screenshot ──────────────────────────────────────────
541            "screenshot" => {
542                self.check_idle_timeout().await?;
543                let slot = self.tab.lock().await;
544                let tab = require_tab(&slot)?;
545                let png = tab.screenshot(width).await.map_err(browser_err)?;
546                let size_bytes = png.len();
547                let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
548                let img = oxi_ai::ContentBlock::Image(oxi_ai::ImageContent::new(b64, "image/png"));
549
550                Ok(AgentToolResult::success(json_str(&json!({
551                    "status": "ok",
552                    "size_bytes": size_bytes,
553                })))
554                .with_content_blocks(vec![img]))
555            }
556
557            // ── Extended DOM actions ──────────────────────────────
558            "scroll_into_view" => {
559                self.check_idle_timeout().await?;
560                let sel =
561                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
562                let slot = self.tab.lock().await;
563                let tab = require_tab(&slot)?;
564                tab.scroll_into_view(sel).await.map_err(browser_err)?;
565                Ok(json_ok())
566            }
567
568            "hover" => {
569                self.check_idle_timeout().await?;
570                let sel =
571                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
572                let slot = self.tab.lock().await;
573                let tab = require_tab(&slot)?;
574                tab.hover(sel).await.map_err(browser_err)?;
575                Ok(json_ok())
576            }
577
578            "double_click" => {
579                self.check_idle_timeout().await?;
580                let sel =
581                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
582                let slot = self.tab.lock().await;
583                let tab = require_tab(&slot)?;
584                tab.double_click(sel).await.map_err(browser_err)?;
585                Ok(json_ok())
586            }
587
588            "right_click" => {
589                self.check_idle_timeout().await?;
590                let sel =
591                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
592                let slot = self.tab.lock().await;
593                let tab = require_tab(&slot)?;
594                tab.right_click(sel).await.map_err(browser_err)?;
595                Ok(json_ok())
596            }
597
598            "drag" => {
599                self.check_idle_timeout().await?;
600                let from = from_selector
601                    .ok_or_else(|| "Missing required parameter: from_selector".to_string())?;
602                let to = to_selector
603                    .ok_or_else(|| "Missing required parameter: to_selector".to_string())?;
604                let slot = self.tab.lock().await;
605                let tab = require_tab(&slot)?;
606                tab.drag(from, to).await.map_err(browser_err)?;
607                Ok(json_ok())
608            }
609
610            "upload_file" => {
611                self.check_idle_timeout().await?;
612                let sel =
613                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
614                let path =
615                    file_path.ok_or_else(|| "Missing required parameter: file_path".to_string())?;
616                let slot = self.tab.lock().await;
617                let tab = require_tab(&slot)?;
618                tab.upload_file(sel, path).await.map_err(browser_err)?;
619                Ok(json_ok())
620            }
621
622            "get_value" => {
623                self.check_idle_timeout().await?;
624                let sel =
625                    selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
626                let slot = self.tab.lock().await;
627                let tab = require_tab(&slot)?;
628                let result_val = tab.get_value(sel).await.map_err(browser_err)?;
629                Ok(AgentToolResult::success(json_str(&json!({
630                    "status": "ok",
631                    "value": result_val,
632                }))))
633            }
634
635            _ => Err(format!(
636                "Unknown action: '{}'. Valid actions: open, goto, back, forward, reload, \
637                 click, fill, type, clear, press, select, check, uncheck, scroll, \
638                 scroll_into_view, hover, double_click, right_click, drag, upload_file, \
639                 wait_for, content, query_all, extract_links, evaluate, evaluate_await, \
640                 get_value, screenshot, close",
641                action
642            )),
643        }
644    }
645}
646
647// ── Helpers ───────────────────────────────────────────────────────────────────
648
649/// Get a reference to the tab from the locked slot, or return an error.
650fn require_tab(slot: &Option<TabGuard>) -> Result<&dyn super::engine::BrowserTab, ToolError> {
651    match slot {
652        Some(guard) => Ok(guard.tab()),
653        None => Err(BrowserError::NoActiveSession.into()),
654    }
655}
656
657/// Serialize a JSON value to a pretty string.
658fn json_str(v: &Value) -> String {
659    serde_json::to_string_pretty(v).unwrap_or_default()
660}
661
662/// Create a JSON success result.
663fn json_ok() -> AgentToolResult {
664    AgentToolResult::success(json_str(&json!({ "status": "ok" })))
665}
666
667/// Create a JSON error result (still `success: true` — error is in the payload).
668fn json_error(msg: &str) -> AgentToolResult {
669    AgentToolResult::success(json_str(&json!({
670        "status": "error",
671        "error": msg,
672    })))
673}
674
675/// Convert a `BrowserError` into a `ToolError`.
676fn browser_err(e: BrowserError) -> ToolError {
677    e.to_string()
678}
679
680// ── Tests ─────────────────────────────────────────────────────────────────────
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::tools::browse::engine::{BrowserError, PageContent};
686    use async_trait::async_trait;
687    use std::sync::atomic::{AtomicBool, Ordering};
688
689    // ── Mock tab for unit tests ─────────────────────────────────
690
691    struct MockTab {
692        closed: Arc<AtomicBool>,
693    }
694
695    impl MockTab {
696        fn new() -> (Self, Arc<AtomicBool>) {
697            let closed = Arc::new(AtomicBool::new(false));
698            (
699                Self {
700                    closed: closed.clone(),
701                },
702                closed,
703            )
704        }
705    }
706
707    #[async_trait]
708    impl super::super::engine::BrowserTab for MockTab {
709        async fn goto(&self, _url: &str) -> Result<PageContent, BrowserError> {
710            Ok(PageContent {
711                url: "https://example.com".into(),
712                title: "Example".into(),
713                status: 200,
714                markdown: "# Example\nHello".into(),
715                html: "<h1>Example</h1>".into(),
716            })
717        }
718        async fn click(&self, _selector: &str) -> Result<(), BrowserError> {
719            Ok(())
720        }
721        async fn type_(&self, _selector: &str, _text: &str) -> Result<(), BrowserError> {
722            Ok(())
723        }
724        async fn fill(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
725            Ok(())
726        }
727        async fn press(&self, _combo: &str) -> Result<(), BrowserError> {
728            Ok(())
729        }
730        async fn wait_for(&self, _selector: &str, _timeout_ms: u64) -> Result<(), BrowserError> {
731            Ok(())
732        }
733        async fn content(&self) -> Result<PageContent, BrowserError> {
734            Ok(PageContent {
735                url: "https://example.com".into(),
736                title: "Example".into(),
737                status: 200,
738                markdown: "# Example\nHello".into(),
739                html: "<h1>Example</h1>".into(),
740            })
741        }
742        async fn query_all(&self, _selector: &str) -> Result<Vec<String>, BrowserError> {
743            Ok(vec!["item1".into(), "item2".into()])
744        }
745        async fn evaluate(&self, _js: &str) -> Result<Value, BrowserError> {
746            Ok(Value::String("ok".into()))
747        }
748        async fn screenshot(&self, _width: u32) -> Result<Vec<u8>, BrowserError> {
749            Ok(vec![0x89, 0x50, 0x4E, 0x47]) // PNG magic bytes
750        }
751        async fn close(&self) -> Result<(), BrowserError> {
752            self.closed.store(true, Ordering::SeqCst);
753            Ok(())
754        }
755        async fn back(&self) -> Result<PageContent, BrowserError> {
756            Ok(PageContent::empty())
757        }
758        async fn forward(&self) -> Result<PageContent, BrowserError> {
759            Ok(PageContent::empty())
760        }
761        async fn reload(&self) -> Result<PageContent, BrowserError> {
762            Ok(PageContent::empty())
763        }
764        async fn select_option(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
765            Ok(())
766        }
767        async fn check(&self, _selector: &str) -> Result<(), BrowserError> {
768            Ok(())
769        }
770        async fn uncheck(&self, _selector: &str) -> Result<(), BrowserError> {
771            Ok(())
772        }
773        async fn hover(&self, _selector: &str) -> Result<(), BrowserError> {
774            Ok(())
775        }
776        async fn double_click(&self, _selector: &str) -> Result<(), BrowserError> {
777            Ok(())
778        }
779        async fn right_click(&self, _selector: &str) -> Result<(), BrowserError> {
780            Ok(())
781        }
782        async fn scroll_into_view(&self, _selector: &str) -> Result<(), BrowserError> {
783            Ok(())
784        }
785        async fn drag(&self, _from_selector: &str, _to_selector: &str) -> Result<(), BrowserError> {
786            Ok(())
787        }
788        async fn upload_file(&self, _selector: &str, _path: &str) -> Result<(), BrowserError> {
789            Ok(())
790        }
791        async fn get_value(&self, _selector: &str) -> Result<String, BrowserError> {
792            Ok("mock_value".into())
793        }
794        async fn evaluate_await(&self, _js: &str) -> Result<Value, BrowserError> {
795            Ok(Value::String("ok".into()))
796        }
797    }
798
799    // ── Mock engine ─────────────────────────────────────────────
800
801    struct MockEngine;
802
803    #[async_trait]
804    impl super::super::engine::BrowserEngine for MockEngine {
805        async fn new_tab(&self) -> Result<Box<dyn super::super::engine::BrowserTab>, BrowserError> {
806            let (tab, _) = MockTab::new();
807            Ok(Box::new(tab))
808        }
809        async fn close(&self) -> Result<(), BrowserError> {
810            Ok(())
811        }
812        async fn is_alive(&self) -> bool {
813            true
814        }
815    }
816
817    /// Create a tool with a mock engine for testing.
818    fn make_tool() -> BrowseSessionTool {
819        let engine: Arc<dyn BrowserEngine> = Arc::new(MockEngine);
820        BrowseSessionTool::new(engine)
821    }
822
823    // ── Tests ──────────────────────────────────────────────────
824
825    #[tokio::test]
826    async fn test_open_close_lifecycle() {
827        let tool = make_tool();
828        let ctx = ToolContext::default();
829
830        let result = tool
831            .execute("c1", json!({"action": "open"}), None, &ctx)
832            .await
833            .unwrap();
834        assert!(result.success);
835        assert!(result.output.contains("ok"));
836
837        let result = tool
838            .execute("c2", json!({"action": "close"}), None, &ctx)
839            .await
840            .unwrap();
841        assert!(result.success);
842    }
843
844    #[tokio::test]
845    async fn test_goto_requires_open_session() {
846        let tool = make_tool();
847        let ctx = ToolContext::default();
848
849        let result = tool
850            .execute(
851                "c1",
852                json!({"action": "goto", "url": "https://example.com"}),
853                None,
854                &ctx,
855            )
856            .await;
857        assert!(result.is_err());
858        assert!(result
859            .unwrap_err()
860            .to_string()
861            .contains("no active session"));
862    }
863
864    #[tokio::test]
865    async fn test_open_goto_close() {
866        let tool = make_tool();
867        let ctx = ToolContext::default();
868
869        tool.execute("c1", json!({"action": "open"}), None, &ctx)
870            .await
871            .unwrap();
872
873        let result = tool
874            .execute(
875                "c2",
876                json!({"action": "goto", "url": "https://example.com"}),
877                None,
878                &ctx,
879            )
880            .await
881            .unwrap();
882        assert!(result.success);
883        assert!(result.output.contains("example.com"));
884        assert!(result.output.contains("200"));
885
886        let result = tool
887            .execute("c3", json!({"action": "close"}), None, &ctx)
888            .await
889            .unwrap();
890        assert!(result.success);
891    }
892
893    #[tokio::test]
894    async fn test_content_action() {
895        let tool = make_tool();
896        let ctx = ToolContext::default();
897
898        tool.execute("c1", json!({"action": "open"}), None, &ctx)
899            .await
900            .unwrap();
901        tool.execute(
902            "c2",
903            json!({"action": "goto", "url": "https://example.com"}),
904            None,
905            &ctx,
906        )
907        .await
908        .unwrap();
909
910        let result = tool
911            .execute(
912                "c3",
913                json!({"action": "content", "format": "markdown"}),
914                None,
915                &ctx,
916            )
917            .await
918            .unwrap();
919        assert!(result.success);
920        assert!(result.output.contains("Example"));
921        assert!(result.output.contains("Hello"));
922
923        tool.execute("c4", json!({"action": "close"}), None, &ctx)
924            .await
925            .unwrap();
926    }
927
928    #[tokio::test]
929    async fn test_query_all_action() {
930        let tool = make_tool();
931        let ctx = ToolContext::default();
932
933        tool.execute("c1", json!({"action": "open"}), None, &ctx)
934            .await
935            .unwrap();
936
937        let result = tool
938            .execute(
939                "c2",
940                json!({"action": "query_all", "selector": ".item"}),
941                None,
942                &ctx,
943            )
944            .await
945            .unwrap();
946        assert!(result.success);
947        assert!(result.output.contains("item1"));
948        assert!(result.output.contains("item2"));
949
950        tool.execute("c3", json!({"action": "close"}), None, &ctx)
951            .await
952            .unwrap();
953    }
954
955    #[tokio::test]
956    async fn test_evaluate_action() {
957        let tool = make_tool();
958        let ctx = ToolContext::default();
959
960        tool.execute("c1", json!({"action": "open"}), None, &ctx)
961            .await
962            .unwrap();
963
964        let result = tool
965            .execute(
966                "c2",
967                json!({"action": "evaluate", "javascript": "document.title"}),
968                None,
969                &ctx,
970            )
971            .await
972            .unwrap();
973        assert!(result.success);
974        assert!(result.output.contains("ok"));
975
976        tool.execute("c3", json!({"action": "close"}), None, &ctx)
977            .await
978            .unwrap();
979    }
980
981    #[tokio::test]
982    async fn test_screenshot_action() {
983        let tool = make_tool();
984        let ctx = ToolContext::default();
985
986        tool.execute("c1", json!({"action": "open"}), None, &ctx)
987            .await
988            .unwrap();
989
990        let result = tool
991            .execute("c2", json!({"action": "screenshot"}), None, &ctx)
992            .await
993            .unwrap();
994        assert!(result.success);
995        assert!(result.output.contains("size_bytes"));
996        assert!(result.content_blocks.is_some());
997
998        tool.execute("c3", json!({"action": "close"}), None, &ctx)
999            .await
1000            .unwrap();
1001    }
1002
1003    #[tokio::test]
1004    async fn test_dom_actions() {
1005        let tool = make_tool();
1006        let ctx = ToolContext::default();
1007
1008        tool.execute("c1", json!({"action": "open"}), None, &ctx)
1009            .await
1010            .unwrap();
1011
1012        let actions: Vec<(&str, Value)> = vec![
1013            ("click", json!({"action": "click", "selector": "#btn"})),
1014            (
1015                "fill",
1016                json!({"action": "fill", "selector": "#input", "value": "hello"}),
1017            ),
1018            (
1019                "type",
1020                json!({"action": "type", "selector": "#input", "value": "world"}),
1021            ),
1022            ("clear", json!({"action": "clear", "selector": "#input"})),
1023            ("press", json!({"action": "press", "combo": "Enter"})),
1024            ("check", json!({"action": "check", "selector": "#agree"})),
1025            (
1026                "uncheck",
1027                json!({"action": "uncheck", "selector": "#newsletter"}),
1028            ),
1029            ("scroll", json!({"action": "scroll", "pixels": 500})),
1030            (
1031                "wait_for",
1032                json!({"action": "wait_for", "selector": ".loaded"}),
1033            ),
1034            (
1035                "scroll_into_view",
1036                json!({"action": "scroll_into_view", "selector": "#section"}),
1037            ),
1038            ("hover", json!({"action": "hover", "selector": "#menu"})),
1039            (
1040                "double_click",
1041                json!({"action": "double_click", "selector": "#item"}),
1042            ),
1043            (
1044                "right_click",
1045                json!({"action": "right_click", "selector": "#item"}),
1046            ),
1047            (
1048                "get_value",
1049                json!({"action": "get_value", "selector": "#input"}),
1050            ),
1051        ];
1052
1053        for (name, params) in &actions {
1054            let result = tool.execute("cx", params.clone(), None, &ctx).await;
1055            assert!(result.is_ok(), "Action '{}' failed: {:?}", name, result);
1056        }
1057
1058        tool.execute("c99", json!({"action": "close"}), None, &ctx)
1059            .await
1060            .unwrap();
1061    }
1062
1063    #[tokio::test]
1064    async fn test_navigation_actions() {
1065        let tool = make_tool();
1066        let ctx = ToolContext::default();
1067
1068        tool.execute("c1", json!({"action": "open"}), None, &ctx)
1069            .await
1070            .unwrap();
1071
1072        for nav_action in &["back", "forward", "reload"] {
1073            let result = tool
1074                .execute("cx", json!({"action": *nav_action}), None, &ctx)
1075                .await;
1076            assert!(result.is_ok(), "Navigation action '{}' failed", nav_action);
1077        }
1078
1079        tool.execute("c99", json!({"action": "close"}), None, &ctx)
1080            .await
1081            .unwrap();
1082    }
1083
1084    #[tokio::test]
1085    async fn test_unknown_action() {
1086        let tool = make_tool();
1087        let ctx = ToolContext::default();
1088
1089        let result = tool
1090            .execute("c1", json!({"action": "nonexistent"}), None, &ctx)
1091            .await;
1092        assert!(result.is_err());
1093        assert!(result.unwrap_err().to_string().contains("Unknown action"));
1094    }
1095
1096    #[tokio::test]
1097    async fn test_close_without_open() {
1098        let tool = make_tool();
1099        let ctx = ToolContext::default();
1100
1101        let result = tool
1102            .execute("c1", json!({"action": "close"}), None, &ctx)
1103            .await
1104            .unwrap();
1105        assert!(result.success);
1106        assert!(result.output.contains("error"));
1107    }
1108
1109    #[tokio::test]
1110    async fn test_re_open_closes_previous() {
1111        let tool = make_tool();
1112        let ctx = ToolContext::default();
1113
1114        tool.execute("c1", json!({"action": "open"}), None, &ctx)
1115            .await
1116            .unwrap();
1117
1118        let result = tool
1119            .execute("c2", json!({"action": "open"}), None, &ctx)
1120            .await
1121            .unwrap();
1122        assert!(result.success);
1123
1124        let result = tool
1125            .execute(
1126                "c3",
1127                json!({"action": "goto", "url": "https://example.com"}),
1128                None,
1129                &ctx,
1130            )
1131            .await
1132            .unwrap();
1133        assert!(result.success);
1134    }
1135
1136    #[tokio::test]
1137    async fn test_missing_required_params() {
1138        let tool = make_tool();
1139        let ctx = ToolContext::default();
1140
1141        tool.execute("c1", json!({"action": "open"}), None, &ctx)
1142            .await
1143            .unwrap();
1144
1145        // goto without url
1146        assert!(tool
1147            .execute("c2", json!({"action": "goto"}), None, &ctx)
1148            .await
1149            .is_err());
1150
1151        // click without selector
1152        assert!(tool
1153            .execute("c3", json!({"action": "click"}), None, &ctx)
1154            .await
1155            .is_err());
1156
1157        // fill without value
1158        assert!(tool
1159            .execute(
1160                "c4",
1161                json!({"action": "fill", "selector": "#x"}),
1162                None,
1163                &ctx
1164            )
1165            .await
1166            .is_err());
1167
1168        // press without combo
1169        assert!(tool
1170            .execute("c5", json!({"action": "press"}), None, &ctx)
1171            .await
1172            .is_err());
1173
1174        // evaluate without javascript
1175        assert!(tool
1176            .execute("c6", json!({"action": "evaluate"}), None, &ctx)
1177            .await
1178            .is_err());
1179
1180        tool.execute("c7", json!({"action": "close"}), None, &ctx)
1181            .await
1182            .unwrap();
1183    }
1184
1185    #[tokio::test]
1186    async fn test_name_label_description() {
1187        let tool = make_tool();
1188        assert_eq!(tool.name(), "browse_session");
1189        assert_eq!(tool.label(), "Browser Session");
1190        assert!(!tool.description().is_empty());
1191    }
1192
1193    #[tokio::test]
1194    async fn test_schema_has_all_actions() {
1195        let tool = make_tool();
1196        let schema = tool.parameters_schema();
1197        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1198        assert_eq!(actions.len(), 29);
1199    }
1200}