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