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