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