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