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