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::{json, Value};
15use std::sync::Arc;
16use std::time::Instant;
17use tokio::sync::{oneshot, Mutex};
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))
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!(result
919 .unwrap_err()
920 .to_string()
921 .contains("no active session"));
922 }
923
924 #[tokio::test]
925 async fn test_open_goto_close() {
926 let tool = make_tool();
927 let ctx = ToolContext::default();
928
929 tool.execute("c1", json!({"action": "open"}), None, &ctx)
930 .await
931 .unwrap();
932
933 let result = tool
934 .execute(
935 "c2",
936 json!({"action": "goto", "url": "https://example.com"}),
937 None,
938 &ctx,
939 )
940 .await
941 .unwrap();
942 assert!(result.success);
943 assert!(result.output.contains("example.com"));
944 assert!(result.output.contains("200"));
945
946 let result = tool
947 .execute("c3", json!({"action": "close"}), None, &ctx)
948 .await
949 .unwrap();
950 assert!(result.success);
951 }
952
953 #[tokio::test]
954 async fn test_content_action() {
955 let tool = make_tool();
956 let ctx = ToolContext::default();
957
958 tool.execute("c1", json!({"action": "open"}), None, &ctx)
959 .await
960 .unwrap();
961 tool.execute(
962 "c2",
963 json!({"action": "goto", "url": "https://example.com"}),
964 None,
965 &ctx,
966 )
967 .await
968 .unwrap();
969
970 let result = tool
971 .execute(
972 "c3",
973 json!({"action": "content", "format": "markdown"}),
974 None,
975 &ctx,
976 )
977 .await
978 .unwrap();
979 assert!(result.success);
980 assert!(result.output.contains("Example"));
981 assert!(result.output.contains("Hello"));
982
983 tool.execute("c4", json!({"action": "close"}), None, &ctx)
984 .await
985 .unwrap();
986 }
987
988 #[tokio::test]
989 async fn test_query_all_action() {
990 let tool = make_tool();
991 let ctx = ToolContext::default();
992
993 tool.execute("c1", json!({"action": "open"}), None, &ctx)
994 .await
995 .unwrap();
996
997 let result = tool
998 .execute(
999 "c2",
1000 json!({"action": "query_all", "selector": ".item"}),
1001 None,
1002 &ctx,
1003 )
1004 .await
1005 .unwrap();
1006 assert!(result.success);
1007 assert!(result.output.contains("item1"));
1008 assert!(result.output.contains("item2"));
1009
1010 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1011 .await
1012 .unwrap();
1013 }
1014
1015 #[tokio::test]
1016 async fn test_evaluate_action() {
1017 let tool = make_tool();
1018 let ctx = ToolContext::default();
1019
1020 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1021 .await
1022 .unwrap();
1023
1024 let result = tool
1025 .execute(
1026 "c2",
1027 json!({"action": "evaluate", "javascript": "document.title"}),
1028 None,
1029 &ctx,
1030 )
1031 .await
1032 .unwrap();
1033 assert!(result.success);
1034 assert!(result.output.contains("ok"));
1035
1036 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1037 .await
1038 .unwrap();
1039 }
1040
1041 #[tokio::test]
1042 async fn test_screenshot_action() {
1043 let tool = make_tool();
1044 let ctx = ToolContext::default();
1045
1046 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1047 .await
1048 .unwrap();
1049
1050 let result = tool
1051 .execute("c2", json!({"action": "screenshot"}), None, &ctx)
1052 .await
1053 .unwrap();
1054 assert!(result.success);
1055 assert!(result.output.contains("size_bytes"));
1056 assert!(result.content_blocks.is_some());
1057
1058 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1059 .await
1060 .unwrap();
1061 }
1062
1063 #[tokio::test]
1064 async fn test_dom_actions() {
1065 let tool = make_tool();
1066 let ctx = ToolContext::default();
1067
1068 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1069 .await
1070 .unwrap();
1071
1072 let actions: Vec<(&str, Value)> = vec![
1073 ("click", json!({"action": "click", "selector": "#btn"})),
1074 (
1075 "fill",
1076 json!({"action": "fill", "selector": "#input", "value": "hello"}),
1077 ),
1078 (
1079 "type",
1080 json!({"action": "type", "selector": "#input", "value": "world"}),
1081 ),
1082 ("clear", json!({"action": "clear", "selector": "#input"})),
1083 ("press", json!({"action": "press", "combo": "Enter"})),
1084 ("check", json!({"action": "check", "selector": "#agree"})),
1085 (
1086 "uncheck",
1087 json!({"action": "uncheck", "selector": "#newsletter"}),
1088 ),
1089 ("scroll", json!({"action": "scroll", "pixels": 500})),
1090 (
1091 "wait_for",
1092 json!({"action": "wait_for", "selector": ".loaded"}),
1093 ),
1094 (
1095 "scroll_into_view",
1096 json!({"action": "scroll_into_view", "selector": "#section"}),
1097 ),
1098 ("hover", json!({"action": "hover", "selector": "#menu"})),
1099 (
1100 "double_click",
1101 json!({"action": "double_click", "selector": "#item"}),
1102 ),
1103 (
1104 "right_click",
1105 json!({"action": "right_click", "selector": "#item"}),
1106 ),
1107 (
1108 "get_value",
1109 json!({"action": "get_value", "selector": "#input"}),
1110 ),
1111 ];
1112
1113 for (name, params) in &actions {
1114 let result = tool.execute("cx", params.clone(), None, &ctx).await;
1115 assert!(result.is_ok(), "Action '{}' failed: {:?}", name, result);
1116 }
1117
1118 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1119 .await
1120 .unwrap();
1121 }
1122
1123 #[tokio::test]
1124 async fn test_navigation_actions() {
1125 let tool = make_tool();
1126 let ctx = ToolContext::default();
1127
1128 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1129 .await
1130 .unwrap();
1131
1132 for nav_action in &["back", "forward", "reload"] {
1133 let result = tool
1134 .execute("cx", json!({"action": *nav_action}), None, &ctx)
1135 .await;
1136 assert!(result.is_ok(), "Navigation action '{}' failed", nav_action);
1137 }
1138
1139 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1140 .await
1141 .unwrap();
1142 }
1143
1144 #[tokio::test]
1145 async fn test_unknown_action() {
1146 let tool = make_tool();
1147 let ctx = ToolContext::default();
1148
1149 let result = tool
1150 .execute("c1", json!({"action": "nonexistent"}), None, &ctx)
1151 .await;
1152 assert!(result.is_err());
1153 assert!(result.unwrap_err().to_string().contains("Unknown action"));
1154 }
1155
1156 #[tokio::test]
1157 async fn test_close_without_open() {
1158 let tool = make_tool();
1159 let ctx = ToolContext::default();
1160
1161 let result = tool
1162 .execute("c1", json!({"action": "close"}), None, &ctx)
1163 .await
1164 .unwrap();
1165 assert!(result.success);
1166 assert!(result.output.contains("error"));
1167 }
1168
1169 #[tokio::test]
1170 async fn test_re_open_closes_previous() {
1171 let tool = make_tool();
1172 let ctx = ToolContext::default();
1173
1174 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1175 .await
1176 .unwrap();
1177
1178 let result = tool
1179 .execute("c2", json!({"action": "open"}), None, &ctx)
1180 .await
1181 .unwrap();
1182 assert!(result.success);
1183
1184 let result = tool
1185 .execute(
1186 "c3",
1187 json!({"action": "goto", "url": "https://example.com"}),
1188 None,
1189 &ctx,
1190 )
1191 .await
1192 .unwrap();
1193 assert!(result.success);
1194 }
1195
1196 #[tokio::test]
1197 async fn test_missing_required_params() {
1198 let tool = make_tool();
1199 let ctx = ToolContext::default();
1200
1201 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1202 .await
1203 .unwrap();
1204
1205 assert!(tool
1207 .execute("c2", json!({"action": "goto"}), None, &ctx)
1208 .await
1209 .is_err());
1210
1211 assert!(tool
1213 .execute("c3", json!({"action": "click"}), None, &ctx)
1214 .await
1215 .is_err());
1216
1217 assert!(tool
1219 .execute(
1220 "c4",
1221 json!({"action": "fill", "selector": "#x"}),
1222 None,
1223 &ctx
1224 )
1225 .await
1226 .is_err());
1227
1228 assert!(tool
1230 .execute("c5", json!({"action": "press"}), None, &ctx)
1231 .await
1232 .is_err());
1233
1234 assert!(tool
1236 .execute("c6", json!({"action": "evaluate"}), None, &ctx)
1237 .await
1238 .is_err());
1239
1240 tool.execute("c7", json!({"action": "close"}), None, &ctx)
1241 .await
1242 .unwrap();
1243 }
1244
1245 #[tokio::test]
1246 async fn test_name_label_description() {
1247 let tool = make_tool();
1248 assert_eq!(tool.name(), "browse_session");
1249 assert_eq!(tool.label(), "Browser Session");
1250 assert!(!tool.description().is_empty());
1251 }
1252
1253 #[tokio::test]
1254 async fn test_schema_has_all_actions() {
1255 let tool = make_tool();
1256 let schema = tool.parameters_schema();
1257 let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1258 assert_eq!(actions.len(), 29);
1259 }
1260}