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