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