Skip to main content

oxicode_agent/tools/browse/
browse_session_tool.rs

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