1use crate::events::{ToolCallContext, VisitReason};
3use crate::{AgentEvent, AgentToolResult};
4use anyhow::Result;
5use futures::{FutureExt, StreamExt};
6use oxi_ai::{AssistantMessage, Message, ToolCall, ToolResultMessage, progress_callback};
7use std::pin::Pin;
8use std::sync::Arc;
9use tokio::sync::Notify;
10
11fn make_cancellation(loop_ref: &super::AgentLoop) -> Arc<Notify> {
31 let notify = Arc::new(Notify::new());
32 if let Some(flag) = loop_ref.cancel_signal() {
33 let notify_for_task = Arc::clone(¬ify);
34 tokio::spawn(async move {
35 loop {
36 if flag.load(std::sync::atomic::Ordering::SeqCst) {
37 notify_for_task.notify_one();
38 return;
39 }
40 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
41 }
42 });
43 }
44 notify
45}
46
47use super::config::{AfterToolCallHook, ToolExecutionMode};
48use super::helpers::{FinalizedToolCall, create_tool_result_message, should_terminate_batch};
49use crate::tools::ToolContext as ToolExecContext;
50
51fn infer_context(tool_name: &str, args: &serde_json::Value) -> Option<ToolCallContext> {
59 match tool_name {
60 "web_search" => args["query"].as_str().map(|q| ToolCallContext::WebSearch {
61 query: q.into(),
62 engine: args["engines"].as_str().map(String::from),
63 }),
64
65 "browse" => args["url"].as_str().map(|u| ToolCallContext::PageVisit {
66 url: u.into(),
67 reason: Some(VisitReason::DirectNavigation),
68 page_title: None,
69 page_status: None,
70 page_bytes: None,
71 page_duration_ms: None,
72 navigation_error: None,
73 screenshot: None,
74 }),
75
76 "browse_extract" => Some(ToolCallContext::DataExtraction {
77 target: args["selector"].as_str().unwrap_or("data").to_string(),
78 url: args["url"].as_str().map(String::from),
79 result_count: None,
80 page_status: None,
81 page_duration_ms: None,
82 }),
83
84 "browse_session" => {
85 let action = args["action"].as_str().unwrap_or("unknown");
86 if action == "goto" {
88 args["url"].as_str().map(|u| ToolCallContext::PageVisit {
89 url: u.into(),
90 reason: Some(VisitReason::DirectNavigation),
91 page_title: None,
92 page_status: None,
93 page_bytes: None,
94 page_duration_ms: None,
95 navigation_error: None,
96 screenshot: None,
97 })
98 } else {
99 Some(ToolCallContext::SessionAction {
100 action: action.to_string(),
101 url: args["url"].as_str().map(String::from),
102 })
103 }
104 }
105
106 "browse_script" => {
107 let total = args["steps"].as_array().map(|a| a.len()).unwrap_or(0);
108 if total > 0 {
109 Some(ToolCallContext::ScriptStep {
110 current: 0,
111 total,
112 step: "starting".into(),
113 })
114 } else {
115 #[cfg(feature = "native-browser")]
118 {
119 args["script"]
120 .as_str()
121 .and_then(|s| serde_yaml::from_str::<serde_yaml::Value>(s).ok())
122 .as_ref()
123 .and_then(|v| v.get("steps").and_then(|s| s.as_sequence()))
124 .map(|steps| ToolCallContext::ScriptStep {
125 current: 0,
126 total: steps.len(),
127 step: "starting".into(),
128 })
129 }
130 #[cfg(not(feature = "native-browser"))]
131 {
132 None
133 }
134 }
135 }
136
137 _ => None,
138 }
139}
140
141fn enrich_context_from_metadata(
144 context_cell: &Arc<parking_lot::Mutex<Option<ToolCallContext>>>,
145 result: &AgentToolResult,
146) {
147 if let Some(ref meta) = result.metadata
148 && let Some(count) = meta.get("result_count").and_then(|v| v.as_u64())
149 {
150 let mut guard = context_cell.lock();
151 if let Some(ToolCallContext::DataExtraction { result_count, .. }) = &mut *guard {
152 *result_count = Some(count as usize);
153 }
154 }
155}
156
157fn make_browse_enrichment_cb(
168 context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>>,
169) -> crate::tools::browse::BrowseProgressCallback {
170 Arc::new(move |progress: crate::tools::browse::BrowseProgress| {
171 let mut guard = context_cell.lock();
172 match (&mut *guard, &progress) {
173 (
174 Some(ToolCallContext::PageVisit {
175 url,
176 page_title,
177 page_status,
178 page_bytes,
179 page_duration_ms,
180 ..
181 }),
182 crate::tools::browse::BrowseProgress::DocumentReady {
183 url: ready_url,
184 title,
185 status,
186 bytes,
187 duration_ms,
188 },
189 ) => {
190 if url != ready_url {
192 *url = ready_url.clone();
193 }
194 *page_title = Some(title.clone());
195 *page_status = Some(*status);
196 *page_bytes = Some(*bytes);
197 *page_duration_ms = Some(*duration_ms);
198 }
199 (
200 Some(ToolCallContext::DataExtraction {
201 page_status,
202 page_duration_ms,
203 ..
204 }),
205 crate::tools::browse::BrowseProgress::DocumentReady {
206 status,
207 duration_ms,
208 ..
209 },
210 ) => {
211 *page_status = Some(*status);
212 *page_duration_ms = Some(*duration_ms);
213 }
214
215 (
217 Some(ToolCallContext::PageVisit {
218 navigation_error, ..
219 }),
220 crate::tools::browse::BrowseProgress::NavigationFailed { error, .. },
221 ) => {
222 *navigation_error = Some(error.clone());
223 }
224
225 (
227 Some(ToolCallContext::PageVisit { screenshot, .. }),
228 crate::tools::browse::BrowseProgress::ScreenshotCaptured {
229 bytes,
230 width,
231 duration_ms,
232 },
233 ) => {
234 *screenshot = Some(crate::events::ScreenshotMeta {
235 bytes: *bytes,
236 width: *width,
237 duration_ms: *duration_ms,
238 });
239 }
240
241 _ => {}
242 }
243 })
244}
245
246pub(crate) struct ExecutedToolCallBatch {
247 pub messages: Vec<ToolResultMessage>,
248 pub terminate: bool,
249}
250
251enum FinalizedToolCallEntry {
252 Immediate(FinalizedToolCall),
253 Future(Pin<Box<dyn futures::Future<Output = FinalizedToolCall> + Send>>),
254}
255
256pub(crate) struct ExecutedToolCallOutcome {
257 pub result: AgentToolResult,
258 pub is_error: bool,
259}
260
261enum PreparedToolCallKind {
262 Immediate,
263 Prepared,
264}
265
266struct PreparedToolCallOutcome {
267 _kind: PreparedToolCallKind,
268 immediate_result: Option<AgentToolResult>,
269 is_error: bool,
270 tool: Option<Arc<dyn crate::tools::AgentTool>>,
271 tool_call: ToolCall,
272 args: serde_json::Value,
273}
274
275pub(crate) async fn execute_tool_calls(
276 loop_ref: &super::AgentLoop,
277 messages: &mut Vec<Message>,
278 assistant_message: &AssistantMessage,
279 tool_calls: Vec<ToolCall>,
280 emit: &super::EmitFn,
281 ctx: &ToolExecContext,
282) -> Result<ExecutedToolCallBatch> {
283 if loop_ref.config.tool_execution == ToolExecutionMode::Sequential {
284 execute_tool_calls_sequential(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
285 .await
286 } else {
287 execute_tool_calls_parallel(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
288 .await
289 }
290}
291
292async fn execute_tool_calls_sequential(
293 loop_ref: &super::AgentLoop,
294 _messages: &mut Vec<Message>,
295 _assistant_message: &AssistantMessage,
296 tool_calls: Vec<ToolCall>,
297 emit: &super::EmitFn,
298 ctx: &ToolExecContext,
299) -> Result<ExecutedToolCallBatch> {
300 let mut finalized_calls = Vec::new();
301 let mut tool_result_messages = Vec::new();
302
303 for tool_call in tool_calls {
304 if loop_ref.is_cancelled() {
308 tracing::info!(
309 "[TOOL-EXEC] Cancelled before executing tool {}",
310 tool_call.name
311 );
312 break;
313 }
314 let tc_id = tool_call.id.clone();
316 let tc_name = tool_call.name.clone();
317 let tc_args = tool_call.arguments.clone();
318
319 emit(AgentEvent::ToolExecutionStart {
320 tool_call_id: tc_id.clone(),
321 tool_name: tc_name.clone(),
322 args: tc_args.clone(),
323 context: infer_context(&tc_name, &tc_args),
324 });
325
326 let prepared = prepare_tool_call(loop_ref, &tool_call).await;
327
328 let finalized = if let Some(result) = prepared.immediate_result {
329 FinalizedToolCall {
330 tool_call,
331 result,
332 is_error: prepared.is_error,
333 }
334 } else {
335 let executed = execute_prepared_tool_call(loop_ref, &prepared, emit, ctx).await;
336
337 let mut result = executed.result;
338 let mut is_error = executed.is_error;
339
340 if let Some(ref hook) = loop_ref.after_tool_call
341 && let Some(modified) = hook(&tc_name, &result).await.ok().flatten()
342 {
343 if let Some(ref details) = modified.metadata {
344 tracing::debug!(
345 tool = %tc_name,
346 details = %details,
347 "after_tool_call hook returned details"
348 );
349 }
350 result = modified;
351 is_error = !result.success;
352 }
353
354 FinalizedToolCall {
355 tool_call,
356 result,
357 is_error,
358 }
359 };
360
361 emit(AgentEvent::ToolExecutionEnd {
362 tool_call_id: finalized.tool_call.id.clone(),
363 tool_name: finalized.tool_call.name.clone(),
364 result: oxi_ai::ToolResult {
365 tool_call_id: finalized.tool_call.id.clone(),
366 content: finalized.result.output.clone(),
367 status: if finalized.is_error {
368 String::from("error")
369 } else {
370 String::from("success")
371 },
372 },
373 is_error: finalized.is_error,
374 });
375
376 let tool_result_message = create_tool_result_message(&finalized);
377 let msg = Message::ToolResult(tool_result_message.clone());
378 emit(AgentEvent::MessageStart {
379 message: msg.clone(),
380 });
381 emit(AgentEvent::MessageEnd { message: msg });
382
383 finalized_calls.push(finalized);
384 tool_result_messages.push(tool_result_message);
385 }
386
387 Ok(ExecutedToolCallBatch {
388 messages: tool_result_messages,
389 terminate: should_terminate_batch(&finalized_calls),
390 })
391}
392
393async fn execute_tool_calls_parallel(
394 loop_ref: &super::AgentLoop,
395 _messages: &mut Vec<Message>,
396 _assistant_message: &AssistantMessage,
397 tool_calls: Vec<ToolCall>,
398 emit: &super::EmitFn,
399 ctx: &ToolExecContext,
400) -> Result<ExecutedToolCallBatch> {
401 let mut finalized_calls: Vec<FinalizedToolCallEntry> = Vec::new();
402
403 for tool_call in tool_calls {
404 if loop_ref.is_cancelled() {
406 tracing::info!(
407 "[TOOL-EXEC-PARALLEL] Cancelled before preparing tool {}",
408 tool_call.name
409 );
410 break;
411 }
412 let tc_id = tool_call.id.clone();
414 let tc_name = tool_call.name.clone();
415 let tc_args = tool_call.arguments.clone();
416
417 emit(AgentEvent::ToolExecutionStart {
418 tool_call_id: tc_id.clone(),
419 tool_name: tc_name.clone(),
420 args: tc_args.clone(),
421 context: infer_context(&tc_name, &tc_args),
422 });
423
424 let prepared = prepare_tool_call(loop_ref, &tool_call).await;
425
426 if let Some(result) = prepared.immediate_result {
427 let finalized = FinalizedToolCall {
428 tool_call,
429 result,
430 is_error: prepared.is_error,
431 };
432
433 emit(AgentEvent::ToolExecutionEnd {
434 tool_call_id: finalized.tool_call.id.clone(),
435 tool_name: finalized.tool_call.name.clone(),
436 result: oxi_ai::ToolResult {
437 tool_call_id: finalized.tool_call.id.clone(),
438 content: finalized.result.output.clone(),
439 status: if finalized.is_error {
440 String::from("error")
441 } else {
442 String::from("success")
443 },
444 },
445 is_error: finalized.is_error,
446 });
447
448 finalized_calls.push(FinalizedToolCallEntry::Immediate(finalized));
449 } else {
450 let tool = prepared.tool.clone();
451 let args = prepared.args.clone();
452 let after_hook = loop_ref.after_tool_call.clone();
453 let emit_clone = emit.clone();
454 let ctx_clone = ctx.clone();
455 let cancel_notify = make_cancellation(loop_ref);
463
464 finalized_calls.push(FinalizedToolCallEntry::Future(Box::pin(async move {
465 let executed = execute_prepared_tool_call_static(
466 tool_call.clone(),
467 tool,
468 args,
469 after_hook.clone(),
470 emit_clone.clone(),
471 &ctx_clone,
472 Some(cancel_notify),
473 )
474 .await;
475
476 FinalizedToolCall {
477 tool_call,
478 result: executed.result,
479 is_error: executed.is_error,
480 }
481 })));
482 }
483 }
484
485 let mut slots: Vec<Option<FinalizedToolCall>> = Vec::with_capacity(finalized_calls.len());
486 #[allow(clippy::type_complexity)]
487 let mut pending_futures: Vec<(
488 usize,
489 Pin<Box<dyn futures::Future<Output = FinalizedToolCall> + Send>>,
490 )> = Vec::new();
491
492 for (i, entry) in finalized_calls.into_iter().enumerate() {
493 match entry {
494 FinalizedToolCallEntry::Immediate(f) => slots.push(Some(f)),
495 FinalizedToolCallEntry::Future(f) => {
496 slots.push(None);
497 pending_futures.push((i, f));
498 }
499 }
500 }
501
502 if !pending_futures.is_empty() {
503 let mut active = futures::stream::FuturesUnordered::new();
507 for (i, f) in pending_futures {
508 active.push(async move { (i, f.await) });
509 }
510
511 let mut cancel_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
514 cancel_interval.tick().await; loop {
517 tokio::select! {
518 result = active.next() => {
519 match result {
520 Some((idx, finalized)) => {
521 slots[idx] = Some(finalized);
522 }
523 None => break, }
525 }
526 _ = cancel_interval.tick() => {
527 if loop_ref.is_cancelled() {
528 tracing::info!(
529 "[TOOL-EXEC-PARALLEL] Cancelled during parallel execution, waiting for {} pending futures",
530 active.len()
531 );
532 break;
535 }
536 }
537 }
538 }
539
540 while let Some(result) = active.next().now_or_never().flatten() {
542 slots[result.0] = Some(result.1);
543 }
544 }
545
546 let ordered_finalized_calls: Vec<FinalizedToolCall> = slots.into_iter().flatten().collect();
548
549 let mut tool_result_messages = Vec::new();
550 for finalized in &ordered_finalized_calls {
551 let tool_result_message = create_tool_result_message(finalized);
552 let msg = Message::ToolResult(tool_result_message.clone());
553 emit(AgentEvent::MessageStart {
554 message: msg.clone(),
555 });
556 emit(AgentEvent::MessageEnd { message: msg });
557 tool_result_messages.push(tool_result_message);
558 }
559
560 Ok(ExecutedToolCallBatch {
561 messages: tool_result_messages,
562 terminate: should_terminate_batch(&ordered_finalized_calls),
563 })
564}
565
566pub(crate) async fn execute_prepared_tool_call_static(
567 tool_call: ToolCall,
568 tool: Option<Arc<dyn crate::tools::AgentTool>>,
569 args: serde_json::Value,
570 after_hook: Option<AfterToolCallHook>,
571 emit: Arc<dyn Fn(AgentEvent) + Send + Sync>,
572 ctx: &ToolExecContext,
573 cancel_notify: Option<Arc<Notify>>,
574) -> ExecutedToolCallOutcome {
575 let tool_call_id = tool_call.id.clone();
576 let tool_name = tool_call.name.clone();
577
578 let mut result = AgentToolResult::success("");
579 let mut is_error = false;
580
581 if let Some(ref tool) = tool {
582 let context = infer_context(&tool_name, &args);
584
585 let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
587 Arc::new(parking_lot::Mutex::new(context));
588
589 let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
591 Arc::new(parking_lot::Mutex::new(None));
592 tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
593
594 let emit_for_cb = emit.clone();
596 let tcid = tool_call_id.clone();
597 let tn = tool_name.clone();
598 let cc = Arc::clone(&context_cell);
599 let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
600 let tab_id = *tab_id_slot.lock();
601 let ctx = cc.lock().clone();
602 emit_for_cb(AgentEvent::ToolExecutionUpdate {
603 tool_call_id: tcid.clone(),
604 tool_name: tn.clone(),
605 partial_result: msg,
606 tab_id,
607 context: ctx,
608 });
609 });
610 tool.on_progress(progress_callback(move |msg: String| {
611 progress_cb(msg);
612 }));
613
614 tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
616 let exec_fut = tool.execute(&tool_call_id, args, None, ctx);
620 tokio::pin!(exec_fut);
621 let exec_result: Result<AgentToolResult, String> = match cancel_notify {
622 Some(notify) => {
623 let notify_for_select = Arc::clone(¬ify);
624 tokio::select! {
625 r = &mut exec_fut => r,
626 _ = notify_for_select.notified() => Err(format!(
627 "tool '{}' cancelled by agent loop",
628 tool_call_id
629 )),
630 }
631 }
632 None => exec_fut.await,
633 };
634 match exec_result {
635 Ok(r) => result = r,
636 Err(e) => {
637 result = AgentToolResult::error(e);
638 is_error = true;
639 }
640 }
641
642 enrich_context_from_metadata(&context_cell, &result);
643 }
644
645 if let Some(ref hook) = after_hook
646 && let Some(modified) = hook(&tool_call.name, &result).await.ok().flatten()
647 {
648 if let Some(ref details) = modified.metadata {
649 tracing::debug!(
650 tool = %tool_call.name,
651 details = %details,
652 "after_tool_call hook returned details"
653 );
654 }
655 result = modified;
656 is_error = !result.success;
657 }
658
659 emit(AgentEvent::ToolExecutionEnd {
660 tool_call_id: tool_call_id.clone(),
661 tool_name: tool_name.clone(),
662 result: oxi_ai::ToolResult {
663 tool_call_id,
664 content: result.output.clone(),
665 status: if is_error {
666 String::from("error")
667 } else {
668 String::from("success")
669 },
670 },
671 is_error,
672 });
673
674 ExecutedToolCallOutcome { result, is_error }
675}
676
677async fn prepare_tool_call(
678 loop_ref: &super::AgentLoop,
679 tool_call: &ToolCall,
680) -> PreparedToolCallOutcome {
681 let tool = match loop_ref.tools.get(&tool_call.name) {
682 Some(t) => t,
683 None => {
684 return PreparedToolCallOutcome {
685 _kind: PreparedToolCallKind::Immediate,
686 immediate_result: Some(AgentToolResult::error(format!(
687 "Tool '{}' not found",
688 tool_call.name
689 ))),
690 is_error: true,
691 tool: None,
692 tool_call: tool_call.clone(),
693 args: tool_call.arguments.clone(),
694 };
695 }
696 };
697
698 let validated_args = tool_call.arguments.clone();
699
700 if let Some(ref hook) = loop_ref.before_tool_call
701 && let Some(blocked) = hook(&tool_call.name, &validated_args).await.ok().flatten()
702 {
703 return PreparedToolCallOutcome {
704 _kind: PreparedToolCallKind::Immediate,
705 immediate_result: Some(blocked),
706 is_error: true,
707 tool: None,
708 tool_call: tool_call.clone(),
709 args: validated_args,
710 };
711 }
712
713 PreparedToolCallOutcome {
714 _kind: PreparedToolCallKind::Prepared,
715 immediate_result: None,
716 is_error: false,
717 tool: Some(Arc::clone(&tool)),
718 tool_call: tool_call.clone(),
719 args: validated_args,
720 }
721}
722
723async fn execute_prepared_tool_call(
724 loop_ref: &super::AgentLoop,
725 prepared: &PreparedToolCallOutcome,
726 emit: &super::EmitFn,
727 ctx: &ToolExecContext,
728) -> ExecutedToolCallOutcome {
729 let tool_call_id = prepared.tool_call.id.clone();
730 let tool_name = prepared.tool_call.name.clone();
731
732 let mut result = AgentToolResult::success("");
733 let mut is_error = false;
734
735 if let Some(ref tool) = prepared.tool {
736 let tool_call_id_clone = tool_call_id.clone();
737 let tool_name_clone = tool_name.clone();
738 let emit_clone = emit.clone();
739
740 let context = infer_context(&tool_name, &prepared.args);
742
743 let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
746 Arc::new(parking_lot::Mutex::new(context));
747
748 let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
752 Arc::new(parking_lot::Mutex::new(None));
753
754 tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
756
757 let tab_id_slot_cb = Arc::clone(&tab_id_slot);
759 let cc = Arc::clone(&context_cell);
760 let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
761 let tab_id = *tab_id_slot_cb.lock();
762 let ctx = cc.lock().clone();
763 emit_clone(AgentEvent::ToolExecutionUpdate {
764 tool_call_id: tool_call_id_clone.clone(),
765 tool_name: tool_name_clone.clone(),
766 partial_result: msg,
767 tab_id,
768 context: ctx,
769 });
770 });
771
772 tool.on_progress(progress_callback(move |msg: String| {
774 progress_cb(msg);
775 }));
776
777 tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
780
781 let cancel_notify = make_cancellation(loop_ref);
800 let cancel_for_select = Arc::clone(&cancel_notify);
801 let tool_call_id_for_exec = tool_call_id.clone();
802 let exec_fut = tool.execute(&tool_call_id_for_exec, prepared.args.clone(), None, ctx);
803 tokio::pin!(exec_fut);
804 let cancelled_msg = format!("tool '{}' cancelled by agent loop", tool_call_id_for_exec);
805 let cancelled_msg_for_select = cancelled_msg.clone();
806 let exec_result: Result<AgentToolResult, String> = tokio::select! {
807 r = &mut exec_fut => r,
808 _ = cancel_for_select.notified() => Err(cancelled_msg_for_select),
809 };
810 match exec_result {
811 Ok(r) => result = r,
812 Err(e) => {
813 result = AgentToolResult::error(e);
814 is_error = true;
815 }
816 }
817
818 enrich_context_from_metadata(&context_cell, &result);
819 }
820
821 ExecutedToolCallOutcome { result, is_error }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use serde_json::json;
828
829 #[test]
830 fn infer_context_web_search() {
831 let ctx = infer_context("web_search", &json!({ "query": "rust headless browser" }));
832 assert!(matches!(
833 ctx,
834 Some(ToolCallContext::WebSearch { query, .. }) if query == "rust headless browser"
835 ));
836 }
837
838 #[test]
839 fn infer_context_web_search_with_engine() {
840 let ctx = infer_context("web_search", &json!({ "query": "rust", "engines": "bing" }));
841 assert!(matches!(
842 ctx,
843 Some(ToolCallContext::WebSearch { engine: Some(e), .. }) if e == "bing"
844 ));
845 }
846
847 #[test]
848 fn infer_context_browse() {
849 let ctx = infer_context(
850 "browse",
851 &json!({ "url": "https://github.com/example/repo" }),
852 );
853 match ctx {
854 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
855 assert_eq!(url, "https://github.com/example/repo");
856 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
857 }
858 other => panic!("expected PageVisit, got {:?}", other),
859 }
860 }
861
862 #[test]
863 fn infer_context_browse_extract() {
864 let ctx = infer_context(
865 "browse_extract",
866 &json!({ "url": "https://example.com", "selector": ".title" }),
867 );
868 match ctx {
869 Some(ToolCallContext::DataExtraction { target, url, .. }) => {
870 assert_eq!(target, ".title");
871 assert_eq!(url.as_deref(), Some("https://example.com"));
872 }
873 other => panic!("expected DataExtraction, got {:?}", other),
874 }
875 }
876
877 #[test]
878 fn infer_context_browse_session_goto() {
879 let ctx = infer_context(
880 "browse_session",
881 &json!({ "action": "goto", "url": "https://example.com" }),
882 );
883 match ctx {
884 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
885 assert_eq!(url, "https://example.com");
886 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
887 }
888 other => panic!("expected PageVisit, got {:?}", other),
889 }
890 }
891
892 #[test]
893 fn infer_context_browse_session_click() {
894 let ctx = infer_context(
895 "browse_session",
896 &json!({ "action": "click", "selector": "#btn" }),
897 );
898 match ctx {
899 Some(ToolCallContext::SessionAction { action, url }) => {
900 assert_eq!(action, "click");
901 assert!(url.is_none());
902 }
903 other => panic!("expected SessionAction, got {:?}", other),
904 }
905 }
906
907 #[test]
908 fn infer_context_browse_script_with_steps_array() {
909 let ctx = infer_context(
910 "browse_script",
911 &json!({ "steps": [{"goto": "https://example.com"}, {"click": "#btn"}] }),
912 );
913 match ctx {
914 Some(ToolCallContext::ScriptStep {
915 current,
916 total,
917 step,
918 }) => {
919 assert_eq!(current, 0);
920 assert_eq!(total, 2);
921 assert_eq!(step, "starting");
922 }
923 other => panic!("expected ScriptStep, got {:?}", other),
924 }
925 }
926
927 #[test]
928 fn infer_context_browse_script_empty() {
929 let ctx = infer_context("browse_script", &json!({ "script": "" }));
930 #[cfg(feature = "native-browser")]
931 assert!(ctx.is_none());
932 #[cfg(not(feature = "native-browser"))]
933 assert!(ctx.is_none());
934 }
935
936 #[test]
937 fn infer_context_unknown_tool() {
938 let ctx = infer_context("bash", &json!({ "command": "ls" }));
939 assert!(ctx.is_none());
940 }
941
942 #[test]
943 fn infer_context_missing_args() {
944 let ctx = infer_context("browse", &json!({}));
946 assert!(ctx.is_none());
947
948 let ctx = infer_context("web_search", &json!({}));
950 assert!(ctx.is_none());
951 }
952
953 #[test]
954 fn tool_context_serde_roundtrip() {
955 let contexts = vec![
956 ToolCallContext::WebSearch {
957 query: "test".into(),
958 engine: Some("ddg".into()),
959 },
960 ToolCallContext::PageVisit {
961 url: "https://example.com".into(),
962 reason: Some(VisitReason::DirectNavigation),
963 page_title: None,
964 page_status: None,
965 page_bytes: None,
966 page_duration_ms: None,
967 navigation_error: None,
968 screenshot: None,
969 },
970 ToolCallContext::PageVisit {
971 url: "https://example.com".into(),
972 reason: Some(VisitReason::SearchResult { position: 3 }),
973 page_title: None,
974 page_status: None,
975 page_bytes: None,
976 page_duration_ms: None,
977 navigation_error: None,
978 screenshot: None,
979 },
980 ToolCallContext::PageVisit {
981 url: "https://example.com".into(),
982 reason: None,
983 page_title: Some("Example Page".into()),
984 page_status: Some(200),
985 page_bytes: Some(12400),
986 page_duration_ms: Some(245),
987 navigation_error: None,
988 screenshot: None,
989 },
990 ToolCallContext::DataExtraction {
991 target: ".title".into(),
992 url: Some("https://example.com".into()),
993 result_count: None,
994 page_status: None,
995 page_duration_ms: None,
996 },
997 ToolCallContext::DataExtraction {
998 target: ".items".into(),
999 url: Some("https://shop.example.com/products".into()),
1000 result_count: Some(42),
1001 page_status: Some(200),
1002 page_duration_ms: Some(180),
1003 },
1004 ToolCallContext::SessionAction {
1005 action: "goto".into(),
1006 url: Some("https://example.com".into()),
1007 },
1008 ToolCallContext::ScriptStep {
1009 current: 3,
1010 total: 10,
1011 step: "clicking".into(),
1012 },
1013 ];
1014
1015 for ctx in &contexts {
1016 let json = serde_json::to_string(ctx).unwrap();
1017 let restored: ToolCallContext = serde_json::from_str(&json).unwrap();
1018 let json2 = serde_json::to_string(&restored).unwrap();
1019 assert_eq!(json, json2, "roundtrip failed for {:?}", ctx);
1020 }
1021 }
1022
1023 #[test]
1024 fn tool_execution_update_backward_compat() {
1025 let old_json = json!({
1027 "type": "toolExecutionUpdate",
1028 "tool_call_id": "call_123",
1029 "tool_name": "browse",
1030 "partial_result": "Loading...",
1031 "tab_id": null
1032 });
1033 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1034 match event {
1035 crate::events::AgentEvent::ToolExecutionUpdate { context, .. } => {
1036 assert!(context.is_none());
1037 }
1038 other => panic!("expected ToolExecutionUpdate, got {:?}", other),
1039 }
1040 }
1041
1042 #[test]
1043 fn tool_execution_start_backward_compat() {
1044 let old_json = json!({
1046 "type": "toolExecutionStart",
1047 "tool_call_id": "call_123",
1048 "tool_name": "browse",
1049 "args": { "url": "https://example.com" }
1050 });
1051 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1052 match event {
1053 crate::events::AgentEvent::ToolExecutionStart { context, .. } => {
1054 assert!(context.is_none());
1055 }
1056 other => panic!("expected ToolExecutionStart, got {:?}", other),
1057 }
1058 }
1059
1060 #[test]
1061 fn browse_enrichment_callback_fills_page_visit() {
1062 use crate::tools::browse::BrowseProgress;
1063 use std::sync::Arc;
1064
1065 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1066 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1067 url: "https://example.com".into(),
1068 reason: Some(VisitReason::DirectNavigation),
1069 page_title: None,
1070 page_status: None,
1071 page_bytes: None,
1072 page_duration_ms: None,
1073 navigation_error: None,
1074 screenshot: None,
1075 })));
1076 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1077 cb(BrowseProgress::DocumentReady {
1078 url: "https://example.com/final".into(),
1079 title: "Example".into(),
1080 status: 200,
1081 bytes: 4096,
1082 duration_ms: 245,
1083 });
1084 let snapshot = cell.lock().clone();
1085 match snapshot {
1086 Some(ToolCallContext::PageVisit {
1087 url,
1088 page_title,
1089 page_status,
1090 page_bytes,
1091 page_duration_ms,
1092 ..
1093 }) => {
1094 assert_eq!(url, "https://example.com/final");
1095 assert_eq!(page_title.as_deref(), Some("Example"));
1096 assert_eq!(page_status, Some(200));
1097 assert_eq!(page_bytes, Some(4096));
1098 assert_eq!(page_duration_ms, Some(245));
1099 }
1100 other => panic!("expected PageVisit, got {:?}", other),
1101 }
1102 }
1103
1104 #[test]
1105 fn browse_enrichment_callback_fills_data_extraction() {
1106 use crate::tools::browse::BrowseProgress;
1107 use std::sync::Arc;
1108
1109 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1110 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1111 target: ".item".into(),
1112 url: Some("https://shop.example.com".into()),
1113 result_count: None,
1114 page_status: None,
1115 page_duration_ms: None,
1116 })),
1117 );
1118 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1119 cb(BrowseProgress::DocumentReady {
1120 url: "https://shop.example.com".into(),
1121 title: "Shop".into(),
1122 status: 200,
1123 bytes: 8192,
1124 duration_ms: 180,
1125 });
1126 let snapshot = cell.lock().clone();
1127 match snapshot {
1128 Some(ToolCallContext::DataExtraction {
1129 page_status,
1130 page_duration_ms,
1131 ..
1132 }) => {
1133 assert_eq!(page_status, Some(200));
1134 assert_eq!(page_duration_ms, Some(180));
1135 }
1136 other => panic!("expected DataExtraction, got {:?}", other),
1137 }
1138 }
1139
1140 #[test]
1141 fn browse_enrichment_callback_no_op_for_mismatched() {
1142 use crate::tools::browse::BrowseProgress;
1143 use std::sync::Arc;
1144
1145 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1147 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::ScriptStep {
1148 current: 1,
1149 total: 5,
1150 step: "click".into(),
1151 })));
1152 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1153 cb(BrowseProgress::DocumentReady {
1154 url: "x".into(),
1155 title: "t".into(),
1156 status: 200,
1157 bytes: 0,
1158 duration_ms: 0,
1159 });
1160 assert!(matches!(
1162 cell.lock().as_ref(),
1163 Some(ToolCallContext::ScriptStep { .. })
1164 ));
1165 }
1166
1167 #[test]
1168 fn browse_enrichment_callback_fills_navigation_error() {
1169 use crate::tools::browse::BrowseProgress;
1170 use std::sync::Arc;
1171
1172 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1173 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1174 url: "https://example.com".into(),
1175 reason: Some(VisitReason::DirectNavigation),
1176 page_title: None,
1177 page_status: None,
1178 page_bytes: None,
1179 page_duration_ms: None,
1180 navigation_error: None,
1181 screenshot: None,
1182 })));
1183 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1184 cb(BrowseProgress::NavigationFailed {
1185 url: "https://example.com".into(),
1186 error: "connection refused".into(),
1187 });
1188 let snapshot = cell.lock().clone();
1189 match snapshot {
1190 Some(ToolCallContext::PageVisit {
1191 navigation_error, ..
1192 }) => {
1193 assert_eq!(navigation_error.as_deref(), Some("connection refused"));
1194 }
1195 other => panic!("expected PageVisit, got {:?}", other),
1196 }
1197 }
1198
1199 #[test]
1200 fn browse_enrichment_callback_fills_screenshot() {
1201 use crate::tools::browse::BrowseProgress;
1202 use std::sync::Arc;
1203
1204 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1205 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1206 url: "https://example.com".into(),
1207 reason: Some(VisitReason::DirectNavigation),
1208 page_title: None,
1209 page_status: None,
1210 page_bytes: None,
1211 page_duration_ms: None,
1212 navigation_error: None,
1213 screenshot: None,
1214 })));
1215 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1216 cb(BrowseProgress::ScreenshotCaptured {
1217 bytes: 2048,
1218 width: 800,
1219 duration_ms: 120,
1220 });
1221 let snapshot = cell.lock().clone();
1222 match snapshot {
1223 Some(ToolCallContext::PageVisit { screenshot, .. }) => {
1224 let meta = screenshot.expect("screenshot should be set");
1225 assert_eq!(meta.bytes, 2048);
1226 assert_eq!(meta.width, 800);
1227 assert_eq!(meta.duration_ms, 120);
1228 }
1229 other => panic!("expected PageVisit, got {:?}", other),
1230 }
1231 }
1232
1233 #[test]
1234 fn browse_enrichment_callback_navigation_failed_ignores_non_page_visit() {
1235 use crate::tools::browse::BrowseProgress;
1236 use std::sync::Arc;
1237
1238 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1240 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1241 target: ".title".into(),
1242 url: None,
1243 result_count: None,
1244 page_status: None,
1245 page_duration_ms: None,
1246 })),
1247 );
1248 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1249 cb(BrowseProgress::NavigationFailed {
1250 url: "https://example.com".into(),
1251 error: "timeout".into(),
1252 });
1253 assert!(matches!(
1254 cell.lock().as_ref(),
1255 Some(ToolCallContext::DataExtraction { .. })
1256 ));
1257 }
1258}