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 "pdf",
191 "close"
192 ],
193 "description": "Session action to perform"
194 },
195 "url": {
196 "type": "string",
197 "description": "URL to navigate to (goto action)"
198 },
199 "selector": {
200 "type": "string",
201 "description": "CSS selector (click, fill, type, clear, select, check, uncheck, wait_for, query_all, extract_links)"
202 },
203 "value": {
204 "type": "string",
205 "description": "Value to fill/type/select (fill, type, select actions)"
206 },
207 "combo": {
208 "type": "string",
209 "description": "Key combo (press action, e.g. 'Enter', 'Control+a')"
210 },
211 "pixels": {
212 "type": "integer",
213 "description": "Scroll distance in pixels (scroll action, positive = down)"
214 },
215 "javascript": {
216 "type": "string",
217 "description": "JS expression to evaluate (evaluate, evaluate_await actions)"
218 },
219 "format": {
220 "type": "string",
221 "enum": ["markdown", "html", "text", "links"],
222 "default": "markdown",
223 "description": "Output format for content action"
224 },
225 "timeout_ms": {
226 "type": "integer",
227 "default": 10000,
228 "description": "Timeout in ms (wait_for action)"
229 },
230 "wait_condition": {
231 "type": "string",
232 "enum": ["network_idle", "dom_content_loaded", "load"],
233 "default": "network_idle",
234 "description": "Structured wait condition (wait action): network_idle, dom_content_loaded, or load"
235 },
236 "from_selector": {
237 "type": "string",
238 "description": "Source CSS selector (drag action)"
239 },
240 "to_selector": {
241 "type": "string",
242 "description": "Target CSS selector (drag action)"
243 },
244 "file_path": {
245 "type": "string",
246 "description": "Local file path to upload (upload_file action)"
247 },
248 "width": {
249 "type": "integer",
250 "default": 800,
251 "description": "Viewport width for screenshot (default: 800)"
252 }
253 },
254 "required": ["action"]
255 })
256 }
257
258 #[allow(clippy::too_many_lines)]
259 async fn execute(
260 &self,
261 _tool_call_id: &str,
262 params: Value,
263 _signal: Option<oneshot::Receiver<()>>,
264 _ctx: &ToolContext,
265 ) -> Result<AgentToolResult, ToolError> {
266 let action = params["action"]
267 .as_str()
268 .ok_or_else(|| "Missing required parameter: action".to_string())?;
269
270 let url = params["url"].as_str();
271 let selector = params["selector"].as_str();
272 let value = params["value"].as_str();
273 let combo = params["combo"].as_str();
274 let pixels = params["pixels"].as_u64().unwrap_or(300);
275 let javascript = params["javascript"].as_str();
276 let format = params["format"].as_str().unwrap_or("markdown");
277 let timeout_ms = params["timeout_ms"]
278 .as_u64()
279 .unwrap_or(self.config.default_wait_timeout_ms);
280 let width = params["width"]
281 .as_u64()
282 .unwrap_or(self.config.screenshot_width as u64) as u32;
283 let from_selector = params["from_selector"].as_str();
284 let to_selector = params["to_selector"].as_str();
285 let file_path = params["file_path"].as_str();
286
287 tracing::info!(action = %action, "browse_session action");
288
289 self.touch().await;
290
291 match action {
292 "open" => {
294 let mut slot = self.tab.lock().await;
295 if let Some(old_guard) = slot.take() {
297 tracing::warn!("browse_session: closing previous session on re-open");
298 old_guard.close().await;
299 }
300 let raw_tab = self
301 .engine
302 .new_tab()
303 .await
304 .map_err(|e| format!("Failed to open browser tab: {}", e))?;
305
306 let tab_id = raw_tab.tab_id();
309 *self.tab_id_slot.lock().lock() = Some(tab_id);
310
311 self.callbacks
315 .register_on_registry(tab_id, self.engine.callback_registry().as_ref());
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 "wait" => {
492 self.check_idle_timeout().await?;
493 let slot = self.tab.lock().await;
494 let tab = require_tab(&slot)?;
495 let cond = match params["wait_condition"].as_str().unwrap_or("network_idle") {
496 "dom_content_loaded" => super::engine::BrowseWaitCondition::DomContentLoaded,
497 "load" => super::engine::BrowseWaitCondition::Load,
498 _ => super::engine::BrowseWaitCondition::NetworkIdle,
500 };
501 tab.wait_for_condition(&cond, timeout_ms)
502 .await
503 .map_err(browser_err)?;
504 Ok(json_ok())
505 }
506
507 "observe" => {
509 self.check_idle_timeout().await?;
510 let slot = self.tab.lock().await;
511 let tab = require_tab(&slot)?;
512 let obs = tab.observe().await.map_err(browser_err)?;
513 Ok(AgentToolResult::success(
514 serde_json::to_string_pretty(&obs).unwrap_or_default(),
515 ))
516 }
517
518 "content" => {
520 self.check_idle_timeout().await?;
521 let slot = self.tab.lock().await;
522 let tab = require_tab(&slot)?;
523 let page = tab.content().await.map_err(browser_err)?;
524
525 let content = match format {
526 "html" => {
527 if let Some(sel) = selector {
528 tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
529 } else {
530 page.html.clone()
531 }
532 }
533 "links" => {
534 let links = if let Some(sel) = selector {
535 let js = helpers::js_links_within(sel);
536 let value = tab.evaluate(&js).await.map_err(browser_err)?;
537 helpers::parse_link_values(value)
538 } else {
539 helpers::extract_links(tab)
540 .await
541 .map_err(|e: ToolError| e)?
542 };
543 helpers::format_links(&links)
544 }
545 "text" => {
546 if let Some(sel) = selector {
547 tab.query_all(sel).await.map_err(browser_err)?.join("\n")
548 } else {
549 page.markdown.clone()
550 }
551 }
552 _ => {
553 if let Some(sel) = selector {
555 tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
556 } else {
557 page.markdown.clone()
558 }
559 }
560 };
561
562 Ok(AgentToolResult::success(json_str(&json!({
563 "status": "ok",
564 "url": page.url,
565 "title": page.title,
566 "content": content,
567 }))))
568 }
569
570 "query_all" => {
571 self.check_idle_timeout().await?;
572 let sel =
573 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
574 let slot = self.tab.lock().await;
575 let tab = require_tab(&slot)?;
576 let results = tab.query_all(sel).await.map_err(browser_err)?;
577 Ok(AgentToolResult::success(json_str(&json!({
578 "status": "ok",
579 "results": results,
580 }))))
581 }
582
583 "extract_links" => {
584 self.check_idle_timeout().await?;
585 let slot = self.tab.lock().await;
586 let tab = require_tab(&slot)?;
587
588 let links = if let Some(sel) = selector {
589 let js = helpers::js_links_within(sel);
590 let value = tab.evaluate(&js).await.map_err(browser_err)?;
591 helpers::parse_link_values(value)
592 } else {
593 helpers::extract_links(tab)
594 .await
595 .map_err(|e: ToolError| e)?
596 };
597
598 let json_links: Vec<Value> = links
599 .iter()
600 .map(|(text, href)| json!({ "text": text, "href": href }))
601 .collect();
602
603 Ok(AgentToolResult::success(json_str(&json!({
604 "status": "ok",
605 "links": json_links,
606 }))))
607 }
608
609 "evaluate" => {
611 self.check_idle_timeout().await?;
612 let js = javascript
613 .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
614 let slot = self.tab.lock().await;
615 let tab = require_tab(&slot)?;
616 let result_val = tab.evaluate(js).await.map_err(browser_err)?;
617 Ok(AgentToolResult::success(json_str(&json!({
618 "status": "ok",
619 "result": result_val,
620 }))))
621 }
622
623 "evaluate_await" => {
624 self.check_idle_timeout().await?;
625 let js = javascript
626 .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
627 let slot = self.tab.lock().await;
628 let tab = require_tab(&slot)?;
629 let result_val = tab.evaluate_await(js).await.map_err(browser_err)?;
630 Ok(AgentToolResult::success(json_str(&json!({
631 "status": "ok",
632 "result": result_val,
633 }))))
634 }
635
636 "screenshot" => {
638 self.check_idle_timeout().await?;
639 let slot = self.tab.lock().await;
640 let tab = require_tab(&slot)?;
641 let png = tab.screenshot(width).await.map_err(browser_err)?;
642 let size_bytes = png.len();
643 let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
644 let img = oxicode_ai::ContentBlock::Image(oxicode_ai::ImageContent::new(
645 b64,
646 "image/png",
647 ));
648
649 Ok(AgentToolResult::success(json_str(&json!({
650 "status": "ok",
651 "size_bytes": size_bytes,
652 })))
653 .with_content_blocks(vec![img]))
654 }
655
656 "pdf" => {
658 self.check_idle_timeout().await?;
659 let slot = self.tab.lock().await;
660 let tab = require_tab(&slot)?;
661 let pdf = tab.print_to_pdf(width).await.map_err(browser_err)?;
662 let size_bytes = pdf.len();
663 let stamp = std::time::SystemTime::now()
669 .duration_since(std::time::UNIX_EPOCH)
670 .map(|d| d.as_millis())
671 .unwrap_or(0);
672 let tab_id = tab.tab_id();
673 let path = std::env::temp_dir().join(format!("oxicode-{tab_id}-{stamp}.pdf"));
674 std::fs::write(&path, &pdf)
675 .map_err(|e| BrowserError::Backend(format!("failed to persist PDF: {e}")))?;
676 let path_str = path.to_string_lossy().to_string();
677 tracing::info!(
678 width,
679 size_bytes,
680 path = %path_str,
681 "browse: pdf exported"
682 );
683 Ok(AgentToolResult::success(json_str(&json!({
684 "status": "ok",
685 "size_bytes": size_bytes,
686 "width": width,
687 "path": path_str,
688 "note": "Read the PDF with the read tool (or open the file directly).",
689 }))))
690 }
691 "scroll_into_view" => {
693 self.check_idle_timeout().await?;
694 let sel =
695 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
696 let slot = self.tab.lock().await;
697 let tab = require_tab(&slot)?;
698 tab.scroll_into_view(sel).await.map_err(browser_err)?;
699 Ok(json_ok())
700 }
701
702 "hover" => {
703 self.check_idle_timeout().await?;
704 let sel =
705 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
706 let slot = self.tab.lock().await;
707 let tab = require_tab(&slot)?;
708 tab.hover(sel).await.map_err(browser_err)?;
709 Ok(json_ok())
710 }
711
712 "double_click" => {
713 self.check_idle_timeout().await?;
714 let sel =
715 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
716 let slot = self.tab.lock().await;
717 let tab = require_tab(&slot)?;
718 tab.double_click(sel).await.map_err(browser_err)?;
719 Ok(json_ok())
720 }
721
722 "right_click" => {
723 self.check_idle_timeout().await?;
724 let sel =
725 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
726 let slot = self.tab.lock().await;
727 let tab = require_tab(&slot)?;
728 tab.right_click(sel).await.map_err(browser_err)?;
729 Ok(json_ok())
730 }
731
732 "drag" => {
733 self.check_idle_timeout().await?;
734 let from = from_selector
735 .ok_or_else(|| "Missing required parameter: from_selector".to_string())?;
736 let to = to_selector
737 .ok_or_else(|| "Missing required parameter: to_selector".to_string())?;
738 let slot = self.tab.lock().await;
739 let tab = require_tab(&slot)?;
740 tab.drag(from, to).await.map_err(browser_err)?;
741 Ok(json_ok())
742 }
743
744 "upload_file" => {
745 self.check_idle_timeout().await?;
746 let sel =
747 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
748 let path =
749 file_path.ok_or_else(|| "Missing required parameter: file_path".to_string())?;
750 let slot = self.tab.lock().await;
751 let tab = require_tab(&slot)?;
752 tab.upload_file(sel, path).await.map_err(browser_err)?;
753 Ok(json_ok())
754 }
755
756 "get_value" => {
757 self.check_idle_timeout().await?;
758 let sel =
759 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
760 let slot = self.tab.lock().await;
761 let tab = require_tab(&slot)?;
762 let result_val = tab.get_value(sel).await.map_err(browser_err)?;
763 Ok(AgentToolResult::success(json_str(&json!({
764 "status": "ok",
765 "value": result_val,
766 }))))
767 }
768
769 _ => Err(format!(
770 "Unknown action: '{}'. Valid actions: open, goto, back, forward, reload, \
771 click, fill, type, clear, press, select, check, uncheck, scroll, \
772 wait_for, wait, content, observe, query_all, extract_links, evaluate, \
773 evaluate_await, get_value, screenshot, pdf, close",
774 action
775 )),
776 }
777 }
778}
779
780fn require_tab(slot: &Option<TabGuard>) -> Result<&dyn super::engine::BrowserTab, ToolError> {
784 match slot {
785 Some(guard) => Ok(guard.tab()),
786 None => Err(BrowserError::NoActiveSession.into()),
787 }
788}
789
790fn json_str(v: &Value) -> String {
792 serde_json::to_string_pretty(v).unwrap_or_default()
793}
794
795fn json_ok() -> AgentToolResult {
797 AgentToolResult::success(json_str(&json!({ "status": "ok" })))
798}
799
800fn json_error(msg: &str) -> AgentToolResult {
802 AgentToolResult::success(json_str(&json!({
803 "status": "error",
804 "error": msg,
805 })))
806}
807
808fn browser_err(e: BrowserError) -> ToolError {
810 e.to_string()
811}
812
813#[cfg(test)]
816mod tests {
817 use super::*;
818 use crate::tools::browse::engine::{BrowserError, PageContent};
819 use async_trait::async_trait;
820 use std::sync::atomic::{AtomicBool, Ordering};
821
822 struct MockTab {
825 closed: Arc<AtomicBool>,
826 }
827
828 impl MockTab {
829 fn new() -> (Self, Arc<AtomicBool>) {
830 let closed = Arc::new(AtomicBool::new(false));
831 (
832 Self {
833 closed: closed.clone(),
834 },
835 closed,
836 )
837 }
838 }
839
840 #[async_trait]
841 impl super::super::engine::BrowserTab for MockTab {
842 async fn goto(&self, _url: &str) -> Result<PageContent, BrowserError> {
843 Ok(PageContent {
844 url: "https://example.com".into(),
845 title: "Example".into(),
846 status: 200,
847 markdown: "# Example\nHello".into(),
848 html: "<h1>Example</h1>".into(),
849 })
850 }
851 async fn click(&self, _selector: &str) -> Result<(), BrowserError> {
852 Ok(())
853 }
854 async fn type_(&self, _selector: &str, _text: &str) -> Result<(), BrowserError> {
855 Ok(())
856 }
857 async fn fill(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
858 Ok(())
859 }
860 async fn press(&self, _combo: &str) -> Result<(), BrowserError> {
861 Ok(())
862 }
863 async fn wait_for(&self, _selector: &str, _timeout_ms: u64) -> Result<(), BrowserError> {
864 Ok(())
865 }
866 async fn content(&self) -> Result<PageContent, BrowserError> {
867 Ok(PageContent {
868 url: "https://example.com".into(),
869 title: "Example".into(),
870 status: 200,
871 markdown: "# Example\nHello".into(),
872 html: "<h1>Example</h1>".into(),
873 })
874 }
875 async fn query_all(&self, _selector: &str) -> Result<Vec<String>, BrowserError> {
876 Ok(vec!["item1".into(), "item2".into()])
877 }
878 async fn evaluate(&self, _js: &str) -> Result<Value, BrowserError> {
879 Ok(Value::String("ok".into()))
880 }
881 async fn screenshot(&self, _width: u32) -> Result<Vec<u8>, BrowserError> {
882 Ok(vec![0x89, 0x50, 0x4E, 0x47]) }
884 async fn print_to_pdf(&self, _width: u32) -> Result<Vec<u8>, BrowserError> {
885 Ok(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n%%EOF\n".to_vec())
888 }
889 async fn close(&self) -> Result<(), BrowserError> {
890 self.closed.store(true, Ordering::SeqCst);
891 Ok(())
892 }
893 async fn back(&self) -> Result<PageContent, BrowserError> {
894 Ok(PageContent::empty())
895 }
896 async fn forward(&self) -> Result<PageContent, BrowserError> {
897 Ok(PageContent::empty())
898 }
899 async fn reload(&self) -> Result<PageContent, BrowserError> {
900 Ok(PageContent::empty())
901 }
902 async fn select_option(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
903 Ok(())
904 }
905 async fn check(&self, _selector: &str) -> Result<(), BrowserError> {
906 Ok(())
907 }
908 async fn uncheck(&self, _selector: &str) -> Result<(), BrowserError> {
909 Ok(())
910 }
911 async fn hover(&self, _selector: &str) -> Result<(), BrowserError> {
912 Ok(())
913 }
914 async fn double_click(&self, _selector: &str) -> Result<(), BrowserError> {
915 Ok(())
916 }
917 async fn right_click(&self, _selector: &str) -> Result<(), BrowserError> {
918 Ok(())
919 }
920 async fn scroll_into_view(&self, _selector: &str) -> Result<(), BrowserError> {
921 Ok(())
922 }
923 async fn drag(&self, _from_selector: &str, _to_selector: &str) -> Result<(), BrowserError> {
924 Ok(())
925 }
926 async fn upload_file(&self, _selector: &str, _path: &str) -> Result<(), BrowserError> {
927 Ok(())
928 }
929 async fn get_value(&self, _selector: &str) -> Result<String, BrowserError> {
930 Ok("mock_value".into())
931 }
932 async fn evaluate_await(&self, _js: &str) -> Result<Value, BrowserError> {
933 Ok(Value::String("ok".into()))
934 }
935 }
936
937 struct MockEngine;
940
941 #[async_trait]
942 impl super::super::engine::BrowserEngine for MockEngine {
943 async fn new_tab(&self) -> Result<Box<dyn super::super::engine::BrowserTab>, BrowserError> {
944 let (tab, _) = MockTab::new();
945 Ok(Box::new(tab) as Box<dyn super::super::engine::BrowserTab>)
946 }
947 async fn close(&self) -> Result<(), BrowserError> {
948 Ok(())
949 }
950 async fn is_alive(&self) -> bool {
951 true
952 }
953 }
954
955 fn make_tool() -> BrowseSessionTool {
957 let engine: Arc<dyn BrowserEngine> = Arc::new(MockEngine);
958 BrowseSessionTool::new(engine)
959 }
960
961 #[tokio::test]
964 async fn test_open_close_lifecycle() {
965 let tool = make_tool();
966 let ctx = ToolContext::default();
967
968 let result = tool
969 .execute("c1", json!({"action": "open"}), None, &ctx)
970 .await
971 .unwrap();
972 assert!(result.success);
973 assert!(result.output.contains("ok"));
974
975 let result = tool
976 .execute("c2", json!({"action": "close"}), None, &ctx)
977 .await
978 .unwrap();
979 assert!(result.success);
980 }
981
982 #[tokio::test]
983 async fn test_goto_requires_open_session() {
984 let tool = make_tool();
985 let ctx = ToolContext::default();
986
987 let result = tool
988 .execute(
989 "c1",
990 json!({"action": "goto", "url": "https://example.com"}),
991 None,
992 &ctx,
993 )
994 .await;
995 assert!(result.is_err());
996 assert!(
997 result
998 .unwrap_err()
999 .to_string()
1000 .contains("no active session")
1001 );
1002 }
1003
1004 #[tokio::test]
1005 async fn test_open_goto_close() {
1006 let tool = make_tool();
1007 let ctx = ToolContext::default();
1008
1009 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1010 .await
1011 .unwrap();
1012
1013 let result = tool
1014 .execute(
1015 "c2",
1016 json!({"action": "goto", "url": "https://example.com"}),
1017 None,
1018 &ctx,
1019 )
1020 .await
1021 .unwrap();
1022 assert!(result.success);
1023 assert!(result.output.contains("example.com"));
1024 assert!(result.output.contains("200"));
1025
1026 let result = tool
1027 .execute("c3", json!({"action": "close"}), None, &ctx)
1028 .await
1029 .unwrap();
1030 assert!(result.success);
1031 }
1032
1033 #[tokio::test]
1034 async fn test_content_action() {
1035 let tool = make_tool();
1036 let ctx = ToolContext::default();
1037
1038 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1039 .await
1040 .unwrap();
1041 tool.execute(
1042 "c2",
1043 json!({"action": "goto", "url": "https://example.com"}),
1044 None,
1045 &ctx,
1046 )
1047 .await
1048 .unwrap();
1049
1050 let result = tool
1051 .execute(
1052 "c3",
1053 json!({"action": "content", "format": "markdown"}),
1054 None,
1055 &ctx,
1056 )
1057 .await
1058 .unwrap();
1059 assert!(result.success);
1060 assert!(result.output.contains("Example"));
1061 assert!(result.output.contains("Hello"));
1062
1063 tool.execute("c4", json!({"action": "close"}), None, &ctx)
1064 .await
1065 .unwrap();
1066 }
1067
1068 #[tokio::test]
1069 async fn test_query_all_action() {
1070 let tool = make_tool();
1071 let ctx = ToolContext::default();
1072
1073 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1074 .await
1075 .unwrap();
1076
1077 let result = tool
1078 .execute(
1079 "c2",
1080 json!({"action": "query_all", "selector": ".item"}),
1081 None,
1082 &ctx,
1083 )
1084 .await
1085 .unwrap();
1086 assert!(result.success);
1087 assert!(result.output.contains("item1"));
1088 assert!(result.output.contains("item2"));
1089
1090 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1091 .await
1092 .unwrap();
1093 }
1094
1095 #[tokio::test]
1096 async fn test_evaluate_action() {
1097 let tool = make_tool();
1098 let ctx = ToolContext::default();
1099
1100 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1101 .await
1102 .unwrap();
1103
1104 let result = tool
1105 .execute(
1106 "c2",
1107 json!({"action": "evaluate", "javascript": "document.title"}),
1108 None,
1109 &ctx,
1110 )
1111 .await
1112 .unwrap();
1113 assert!(result.success);
1114 assert!(result.output.contains("ok"));
1115
1116 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1117 .await
1118 .unwrap();
1119 }
1120
1121 #[tokio::test]
1122 async fn test_screenshot_action() {
1123 let tool = make_tool();
1124 let ctx = ToolContext::default();
1125
1126 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1127 .await
1128 .unwrap();
1129
1130 let result = tool
1131 .execute("c2", json!({"action": "screenshot"}), None, &ctx)
1132 .await
1133 .unwrap();
1134 assert!(result.success);
1135 assert!(result.output.contains("size_bytes"));
1136 assert!(result.content_blocks.is_some());
1137
1138 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1139 .await
1140 .unwrap();
1141 }
1142
1143 #[tokio::test]
1144 async fn test_pdf_action() {
1145 let tool = make_tool();
1146 let ctx = ToolContext::default();
1147
1148 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1149 .await
1150 .unwrap();
1151
1152 let result = tool
1153 .execute("c2", json!({"action": "pdf", "width": 1024}), None, &ctx)
1154 .await
1155 .unwrap();
1156 assert!(result.success);
1157 assert!(result.output.contains("size_bytes"));
1158 assert!(result.output.contains("\"path\""));
1159 let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
1162 let path = parsed["path"].as_str().expect("path field");
1163 let bytes = std::fs::read(path).expect("read pdf from returned path");
1164 assert!(
1165 bytes.starts_with(b"%PDF-"),
1166 "PDF file should start with %PDF-"
1167 );
1168
1169 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1170 .await
1171 .unwrap();
1172 }
1173
1174 #[tokio::test]
1175 async fn test_dom_actions() {
1176 let tool = make_tool();
1177 let ctx = ToolContext::default();
1178
1179 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1180 .await
1181 .unwrap();
1182
1183 let actions: Vec<(&str, Value)> = vec![
1184 ("click", json!({"action": "click", "selector": "#btn"})),
1185 (
1186 "fill",
1187 json!({"action": "fill", "selector": "#input", "value": "hello"}),
1188 ),
1189 (
1190 "type",
1191 json!({"action": "type", "selector": "#input", "value": "world"}),
1192 ),
1193 ("clear", json!({"action": "clear", "selector": "#input"})),
1194 ("press", json!({"action": "press", "combo": "Enter"})),
1195 ("check", json!({"action": "check", "selector": "#agree"})),
1196 (
1197 "uncheck",
1198 json!({"action": "uncheck", "selector": "#newsletter"}),
1199 ),
1200 ("scroll", json!({"action": "scroll", "pixels": 500})),
1201 (
1202 "wait_for",
1203 json!({"action": "wait_for", "selector": ".loaded"}),
1204 ),
1205 (
1206 "scroll_into_view",
1207 json!({"action": "scroll_into_view", "selector": "#section"}),
1208 ),
1209 ("hover", json!({"action": "hover", "selector": "#menu"})),
1210 (
1211 "double_click",
1212 json!({"action": "double_click", "selector": "#item"}),
1213 ),
1214 (
1215 "right_click",
1216 json!({"action": "right_click", "selector": "#item"}),
1217 ),
1218 (
1219 "get_value",
1220 json!({"action": "get_value", "selector": "#input"}),
1221 ),
1222 ];
1223
1224 for (name, params) in &actions {
1225 let result = tool.execute("cx", params.clone(), None, &ctx).await;
1226 assert!(result.is_ok(), "Action '{}' failed: {:?}", name, result);
1227 }
1228
1229 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1230 .await
1231 .unwrap();
1232 }
1233
1234 #[tokio::test]
1235 async fn test_navigation_actions() {
1236 let tool = make_tool();
1237 let ctx = ToolContext::default();
1238
1239 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1240 .await
1241 .unwrap();
1242
1243 for nav_action in &["back", "forward", "reload"] {
1244 let result = tool
1245 .execute("cx", json!({"action": *nav_action}), None, &ctx)
1246 .await;
1247 assert!(result.is_ok(), "Navigation action '{}' failed", nav_action);
1248 }
1249
1250 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1251 .await
1252 .unwrap();
1253 }
1254
1255 #[tokio::test]
1256 async fn test_unknown_action() {
1257 let tool = make_tool();
1258 let ctx = ToolContext::default();
1259
1260 let result = tool
1261 .execute("c1", json!({"action": "nonexistent"}), None, &ctx)
1262 .await;
1263 assert!(result.is_err());
1264 assert!(result.unwrap_err().to_string().contains("Unknown action"));
1265 }
1266
1267 #[tokio::test]
1268 async fn test_close_without_open() {
1269 let tool = make_tool();
1270 let ctx = ToolContext::default();
1271
1272 let result = tool
1273 .execute("c1", json!({"action": "close"}), None, &ctx)
1274 .await
1275 .unwrap();
1276 assert!(result.success);
1277 assert!(result.output.contains("error"));
1278 }
1279
1280 #[tokio::test]
1281 async fn test_re_open_closes_previous() {
1282 let tool = make_tool();
1283 let ctx = ToolContext::default();
1284
1285 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1286 .await
1287 .unwrap();
1288
1289 let result = tool
1290 .execute("c2", json!({"action": "open"}), None, &ctx)
1291 .await
1292 .unwrap();
1293 assert!(result.success);
1294
1295 let result = tool
1296 .execute(
1297 "c3",
1298 json!({"action": "goto", "url": "https://example.com"}),
1299 None,
1300 &ctx,
1301 )
1302 .await
1303 .unwrap();
1304 assert!(result.success);
1305 }
1306
1307 #[tokio::test]
1308 async fn test_missing_required_params() {
1309 let tool = make_tool();
1310 let ctx = ToolContext::default();
1311
1312 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1313 .await
1314 .unwrap();
1315
1316 assert!(
1318 tool.execute("c2", json!({"action": "goto"}), None, &ctx)
1319 .await
1320 .is_err()
1321 );
1322
1323 assert!(
1325 tool.execute("c3", json!({"action": "click"}), None, &ctx)
1326 .await
1327 .is_err()
1328 );
1329
1330 assert!(
1332 tool.execute(
1333 "c4",
1334 json!({"action": "fill", "selector": "#x"}),
1335 None,
1336 &ctx
1337 )
1338 .await
1339 .is_err()
1340 );
1341
1342 assert!(
1344 tool.execute("c5", json!({"action": "press"}), None, &ctx)
1345 .await
1346 .is_err()
1347 );
1348
1349 assert!(
1351 tool.execute("c6", json!({"action": "evaluate"}), None, &ctx)
1352 .await
1353 .is_err()
1354 );
1355
1356 tool.execute("c7", json!({"action": "close"}), None, &ctx)
1357 .await
1358 .unwrap();
1359 }
1360
1361 #[tokio::test]
1362 async fn test_name_label_description() {
1363 let tool = make_tool();
1364 assert_eq!(tool.name(), "browse_session");
1365 assert_eq!(tool.label(), "Browser Session");
1366 assert!(!tool.description().is_empty());
1367 }
1368
1369 #[tokio::test]
1370 async fn test_schema_has_all_actions() {
1371 let tool = make_tool();
1372 let schema = tool.parameters_schema();
1373 let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1374 assert_eq!(actions.len(), 32);
1375 }
1376}