Skip to main content

oxicode_agent/agent_loop/
tool_exec.rs

1/// Tool execution logic for agent loop
2use crate::events::{ToolCallContext, VisitReason};
3use crate::{AgentEvent, AgentToolResult};
4use anyhow::Result;
5use futures::{FutureExt, StreamExt};
6use oxicode_ai::{AssistantMessage, Message, ToolCall, ToolResultMessage, progress_callback};
7use std::pin::Pin;
8use std::sync::Arc;
9use tokio::sync::Notify;
10
11/// Build a cancellation [`Notify`] handle for a single tool call, plus a
12/// background tokio task that fires `notify_one()` whenever `cancel_signal`
13/// transitions to `true`. Tools that opt into cancellation await the
14/// returned `Notify` inside a `tokio::select!` against their main work;
15/// when the loop's `cancel_signal` flips, the task wakes the tool within
16/// ~250 ms (the poll cadence) without requiring the tool to know about
17/// the loop's `AtomicBool`.
18///
19/// Why `Notify` instead of `oneshot::Sender`: the `AgentTool` trait already
20/// exposes a `signal: Option<oneshot::Receiver<()>>` parameter, but every
21/// call site was passing `None` (audit finding F-8), defeating the contract.
22/// This helper re-establishes the contract with a primitive that survives
23/// the closure-move semantics of `tokio::spawn` (`oneshot::Sender` cannot
24/// be both moved into the spawned task AND returned to the caller).
25///
26/// `loop_ref` supplies the loop's `cancel_signal` (`Option<Arc<AtomicBool>>`).
27/// When `None` (e.g. tests that don't install a cancel flag) the returned
28/// `Notify` is never fired; the tool simply awaits it until the call ends.
29/// The detached poll task self-terminates when `notify_one` fires.
30fn 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(&notify);
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
51// ── Context inference ─────────────────────────────────────────────────────
52
53/// Infer semantic context from tool name and arguments.
54///
55/// This is the **single point** where the agent loop assigns meaning
56/// to tool calls. Tools themselves remain unaware of semantics — they
57/// just do their work and emit facts.
58fn 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            // "goto" is semantically a page visit, not a generic action.
87            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                // No structured steps array — script is YAML text.
116                // Parsing requires serde_yaml (native-browser feature).
117                #[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
141/// Enrich `context_cell` from `AgentToolResult` metadata after execute.
142/// Handles `result_count` for `DataExtraction` contexts.
143fn 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
157/// Build a `BrowseProgressCallback` that enriches `context_cell` with
158/// structured data from each `BrowseProgress` event.
159///
160/// Mapping:
161/// - `DocumentReady` + `PageVisit` → fills `page_title`, `page_status`,
162///   `page_bytes`, `page_duration_ms`; updates `url` if redirected.
163/// - `DocumentReady` + `DataExtraction` → fills `page_status`, `page_duration_ms`.
164/// - `NavigationFailed` + `PageVisit` → fills `navigation_error`.
165/// - `ScreenshotCaptured` + `PageVisit` → fills `screenshot`.
166/// - All other combinations → no-op.
167fn 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                // Update URL if redirected.
191                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            // ── NavigationFailed → PageVisit.navigation_error ──
216            (
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            // ── ScreenshotCaptured → PageVisit.screenshot ──
226            (
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(Box<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
275/// Check whether a tool call requires approval before execution.
276///
277/// Returns `None` if approval is not required or was granted.
278/// Returns `Some(AgentToolResult)` (error) if denied or approval-required.
279/// In the latter case, also emits [`AgentEvent::ApprovalRequired`].
280async fn check_tool_approval(
281    loop_ref: &super::AgentLoop,
282    tool_call_id: &str,
283    tool_name: &str,
284    args: &serde_json::Value,
285    emit: &super::EmitFn,
286) -> Option<AgentToolResult> {
287    use crate::agent_loop::config::ApprovalDecision;
288
289    let config = &loop_ref.config.approval_config;
290
291    // No tiers configured = no approval gating.
292    if config.require_approval_for.is_empty() {
293        return None;
294    }
295
296    // No hook registered = permissive.
297    let hook = config.hook.as_ref()?;
298
299    // Get the tool's tier. Default Exec (safest) if tool not found.
300    let tier = loop_ref
301        .tools
302        .get(tool_name)
303        .map(|t| t.tool_tier())
304        .unwrap_or(crate::tools::ToolTier::Exec);
305
306    // This tier doesn't require approval.
307    if !config.require_approval_for.contains(&tier) {
308        return None;
309    }
310
311    match hook(tool_name, args).await {
312        Ok(ApprovalDecision::Allow) => None,
313        Ok(ApprovalDecision::Deny(reason)) => {
314            Some(AgentToolResult::error(format!("Access denied: {}", reason)))
315        }
316        Ok(ApprovalDecision::RequireApproval(reason)) => {
317            emit(AgentEvent::ApprovalRequired {
318                tool_call_id: tool_call_id.to_string(),
319                tool_name: tool_name.to_string(),
320                args: args.clone(),
321                reason: reason.clone(),
322                session_id: loop_ref.session_id.clone(),
323            });
324            Some(AgentToolResult::error(format!(
325                "Approval required: {}",
326                reason
327            )))
328        }
329        Err(e) => {
330            tracing::warn!(
331                tool = %tool_name,
332                error = %e,
333                "Approval hook failed, allowing tool call"
334            );
335            None // Allow on hook error (fail open logged).
336        }
337    }
338}
339
340pub(crate) async fn execute_tool_calls(
341    loop_ref: &super::AgentLoop,
342    messages: &mut Vec<Message>,
343    assistant_message: &AssistantMessage,
344    tool_calls: Vec<ToolCall>,
345    emit: &super::EmitFn,
346    ctx: &ToolExecContext,
347) -> Result<ExecutedToolCallBatch> {
348    if loop_ref.config.tool_execution == ToolExecutionMode::Sequential {
349        execute_tool_calls_sequential(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
350            .await
351    } else {
352        execute_tool_calls_parallel(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
353            .await
354    }
355}
356
357async fn execute_tool_calls_sequential(
358    loop_ref: &super::AgentLoop,
359    _messages: &mut Vec<Message>,
360    _assistant_message: &AssistantMessage,
361    tool_calls: Vec<ToolCall>,
362    emit: &super::EmitFn,
363    ctx: &ToolExecContext,
364) -> Result<ExecutedToolCallBatch> {
365    let mut finalized_calls = Vec::new();
366    let mut tool_result_messages = Vec::new();
367
368    for tool_call in tool_calls {
369        // Check cancellation before executing each tool.
370        // This allows Ctrl+C to interrupt a batch of tool calls
371        // without waiting for all of them to complete.
372        if loop_ref.is_cancelled() {
373            tracing::info!(
374                "[TOOL-EXEC] Cancelled before executing tool {}",
375                tool_call.name
376            );
377            break;
378        }
379        // Clone tool_call fields once upfront to avoid repeated clones.
380        let tc_id = tool_call.id.clone();
381        let tc_name = tool_call.name.clone();
382        let tc_args = tool_call.arguments.clone();
383
384        let intent = loop_ref
385            .tools
386            .get(&tc_name)
387            .and_then(|t| Some(t.intent()?.to_string()));
388
389        emit(AgentEvent::ToolExecutionStart {
390            tool_call_id: tc_id.clone(),
391            tool_name: tc_name.clone(),
392            args: tc_args.clone(),
393            intent: intent.clone(),
394            context: infer_context(&tc_name, &tc_args),
395        });
396
397        // Check approval before executing the tool.
398        if let Some(approval_result) =
399            check_tool_approval(loop_ref, &tc_id, &tc_name, &tc_args, emit).await
400        {
401            let finalized = FinalizedToolCall {
402                tool_call,
403                result: approval_result,
404                is_error: true,
405            };
406
407            let end_intent = loop_ref
408                .tools
409                .get(&tc_name)
410                .and_then(|t| Some(t.intent()?.to_string()));
411
412            emit(AgentEvent::ToolExecutionEnd {
413                tool_call_id: finalized.tool_call.id.clone(),
414                tool_name: finalized.tool_call.name.clone(),
415                intent: end_intent,
416                result: oxicode_ai::ToolResult {
417                    tool_call_id: finalized.tool_call.id.clone(),
418                    content: finalized.result.output.clone(),
419                    status: String::from("error"),
420                },
421                is_error: true,
422            });
423
424            let tool_result_message = create_tool_result_message(&finalized);
425            let msg = Message::ToolResult(tool_result_message.clone());
426            emit(AgentEvent::MessageStart {
427                message: msg.clone(),
428            });
429            emit(AgentEvent::MessageEnd { message: msg });
430
431            finalized_calls.push(finalized);
432            tool_result_messages.push(tool_result_message);
433            continue;
434        }
435
436        let prepared = prepare_tool_call(loop_ref, &tool_call).await;
437
438        let finalized = if let Some(result) = prepared.immediate_result {
439            FinalizedToolCall {
440                tool_call,
441                result,
442                is_error: prepared.is_error,
443            }
444        } else {
445            let executed = execute_prepared_tool_call(loop_ref, &prepared, emit, ctx).await;
446
447            let mut result = executed.result;
448            let mut is_error = executed.is_error;
449
450            if let Some(hook) = &loop_ref.after_tool_call {
451                match hook(&tc_name, &result).await {
452                    Ok(Some(modified)) => {
453                        if let Some(ref details) = modified.metadata {
454                            tracing::debug!(
455                                tool = %tc_name,
456                                details = %details,
457                                "after_tool_call hook returned details"
458                            );
459                        }
460                        result = modified;
461                        is_error = !result.success;
462                    }
463                    Ok(None) => {}
464                    Err(hook_err) => {
465                        tracing::warn!(
466                            tool = %tc_name,
467                            error = %hook_err,
468                            "after_tool_call hook failed, creating error result"
469                        );
470                        result = AgentToolResult::error(format!(
471                            "after_tool_call hook failed: {hook_err}"
472                        ));
473                        is_error = true;
474                    }
475                }
476            }
477
478            FinalizedToolCall {
479                tool_call,
480                result,
481                is_error,
482            }
483        };
484
485        let end_intent = finalized.result.intent.clone().or(intent);
486
487        emit(AgentEvent::ToolExecutionEnd {
488            tool_call_id: finalized.tool_call.id.clone(),
489            tool_name: finalized.tool_call.name.clone(),
490            intent: end_intent,
491            result: oxicode_ai::ToolResult {
492                tool_call_id: finalized.tool_call.id.clone(),
493                content: finalized.result.output.clone(),
494                status: if finalized.is_error {
495                    String::from("error")
496                } else {
497                    String::from("success")
498                },
499            },
500            is_error: finalized.is_error,
501        });
502
503        let tool_result_message = create_tool_result_message(&finalized);
504        let msg = Message::ToolResult(tool_result_message.clone());
505        emit(AgentEvent::MessageStart {
506            message: msg.clone(),
507        });
508        emit(AgentEvent::MessageEnd { message: msg });
509
510        finalized_calls.push(finalized);
511        tool_result_messages.push(tool_result_message);
512    }
513
514    Ok(ExecutedToolCallBatch {
515        messages: tool_result_messages,
516        terminate: should_terminate_batch(&finalized_calls),
517    })
518}
519
520async fn execute_tool_calls_parallel(
521    loop_ref: &super::AgentLoop,
522    _messages: &mut Vec<Message>,
523    _assistant_message: &AssistantMessage,
524    tool_calls: Vec<ToolCall>,
525    emit: &super::EmitFn,
526    ctx: &ToolExecContext,
527) -> Result<ExecutedToolCallBatch> {
528    let mut finalized_calls: Vec<FinalizedToolCallEntry> = Vec::new();
529
530    for tool_call in tool_calls {
531        // Check cancellation before preparing each tool.
532        if loop_ref.is_cancelled() {
533            tracing::info!(
534                "[TOOL-EXEC-PARALLEL] Cancelled before preparing tool {}",
535                tool_call.name
536            );
537            break;
538        }
539        // Clone tool_call fields once upfront to avoid repeated clones.
540        let tc_id = tool_call.id.clone();
541        let tc_name = tool_call.name.clone();
542        let tc_args = tool_call.arguments.clone();
543
544        let intent = loop_ref
545            .tools
546            .get(&tc_name)
547            .and_then(|t| Some(t.intent()?.to_string()));
548
549        emit(AgentEvent::ToolExecutionStart {
550            tool_call_id: tc_id.clone(),
551            tool_name: tc_name.clone(),
552            args: tc_args.clone(),
553            intent: intent.clone(),
554            context: infer_context(&tc_name, &tc_args),
555        });
556
557        // Check approval before preparing the tool.
558        if let Some(approval_result) =
559            check_tool_approval(loop_ref, &tc_id, &tc_name, &tc_args, emit).await
560        {
561            let finalized = FinalizedToolCall {
562                tool_call,
563                result: approval_result,
564                is_error: true,
565            };
566
567            let end_intent = loop_ref
568                .tools
569                .get(&tc_name)
570                .and_then(|t| Some(t.intent()?.to_string()));
571
572            emit(AgentEvent::ToolExecutionEnd {
573                tool_call_id: finalized.tool_call.id.clone(),
574                tool_name: finalized.tool_call.name.clone(),
575                intent: end_intent,
576                result: oxicode_ai::ToolResult {
577                    tool_call_id: finalized.tool_call.id.clone(),
578                    content: finalized.result.output.clone(),
579                    status: String::from("error"),
580                },
581                is_error: true,
582            });
583
584            let tool_result_message = create_tool_result_message(&finalized);
585            let msg = Message::ToolResult(tool_result_message.clone());
586            emit(AgentEvent::MessageStart {
587                message: msg.clone(),
588            });
589            emit(AgentEvent::MessageEnd { message: msg });
590
591            finalized_calls.push(FinalizedToolCallEntry::Immediate(Box::new(finalized)));
592            continue;
593        }
594
595        let prepared = prepare_tool_call(loop_ref, &tool_call).await;
596
597        if let Some(result) = prepared.immediate_result {
598            let finalized = FinalizedToolCall {
599                tool_call,
600                result,
601                is_error: prepared.is_error,
602            };
603
604            let end_intent = finalized.result.intent.clone().or(intent);
605
606            emit(AgentEvent::ToolExecutionEnd {
607                tool_call_id: finalized.tool_call.id.clone(),
608                tool_name: finalized.tool_call.name.clone(),
609                intent: end_intent,
610                result: oxicode_ai::ToolResult {
611                    tool_call_id: finalized.tool_call.id.clone(),
612                    content: finalized.result.output.clone(),
613                    status: if finalized.is_error {
614                        String::from("error")
615                    } else {
616                        String::from("success")
617                    },
618                },
619                is_error: finalized.is_error,
620            });
621
622            finalized_calls.push(FinalizedToolCallEntry::Immediate(Box::new(finalized)));
623        } else {
624            let tool = prepared.tool.clone();
625            let args = prepared.args.clone();
626            let after_hook = loop_ref.after_tool_call.clone();
627            let emit_clone = emit.clone();
628            let ctx_clone = ctx.clone();
629            // Pre-build the cancellation notify *outside* the async move
630            // closure so `loop_ref` does not need to be `Send + 'static`.
631            // The helper only borrows `loop_ref` for the duration of the
632            // synchronous body of `make_cancellation` (it returns
633            // `Arc<Notify>` plus a detached `tokio::spawn` that owns the
634            // flag clone, so by the time this scope ends `loop_ref` is
635            // no longer referenced).
636            let cancel_notify = make_cancellation(loop_ref);
637
638            finalized_calls.push(FinalizedToolCallEntry::Future(Box::pin(async move {
639                let executed = execute_prepared_tool_call_static(
640                    tool_call.clone(),
641                    tool,
642                    args,
643                    after_hook.clone(),
644                    emit_clone.clone(),
645                    &ctx_clone,
646                    Some(cancel_notify),
647                )
648                .await;
649
650                FinalizedToolCall {
651                    tool_call,
652                    result: executed.result,
653                    is_error: executed.is_error,
654                }
655            })));
656        }
657    }
658
659    let mut slots: Vec<Option<FinalizedToolCall>> = Vec::with_capacity(finalized_calls.len());
660    #[allow(clippy::type_complexity)]
661    let mut pending_futures: Vec<(
662        usize,
663        Pin<Box<dyn futures::Future<Output = FinalizedToolCall> + Send>>,
664    )> = Vec::new();
665
666    for (i, entry) in finalized_calls.into_iter().enumerate() {
667        match entry {
668            FinalizedToolCallEntry::Immediate(f) => slots.push(Some(*f)),
669            FinalizedToolCallEntry::Future(f) => {
670                slots.push(None);
671                pending_futures.push((i, f));
672            }
673        }
674    }
675
676    if !pending_futures.is_empty() {
677        // Poll futures with periodic cancel checks.
678        // Uses `FuturesUnordered` so we can drain completed results as they
679        // arrive and detect cancellation without waiting for all futures.
680        let mut active = futures::stream::FuturesUnordered::new();
681        for (i, f) in pending_futures {
682            active.push(async move { (i, f.await) });
683        }
684
685        // Check cancel every 100ms so Ctrl+C is responsive even when
686        // tool calls are slow.
687        let mut cancel_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
688        cancel_interval.tick().await; // consume immediate first tick
689
690        loop {
691            tokio::select! {
692                result = active.next() => {
693                    match result {
694                        Some((idx, finalized)) => {
695                            slots[idx] = Some(finalized);
696                        }
697                        None => break, // all futures completed
698                    }
699                }
700                _ = cancel_interval.tick() => {
701                    if loop_ref.is_cancelled() {
702                        tracing::info!(
703                            "[TOOL-EXEC-PARALLEL] Cancelled during parallel execution, waiting for {} pending futures",
704                            active.len()
705                        );
706                        // Don't abort futures — let them finish (they may have
707                        // side effects). But skip waiting and return what we have.
708                        break;
709                    }
710                }
711            }
712        }
713
714        // Drain any remaining futures that completed before cancellation.
715        while let Some(result) = active.next().now_or_never().flatten() {
716            slots[result.0] = Some(result.1);
717        }
718    }
719
720    // Slots for futures that were still running at cancellation time remain None.
721    let ordered_finalized_calls: Vec<FinalizedToolCall> = slots.into_iter().flatten().collect();
722
723    let mut tool_result_messages = Vec::new();
724    for finalized in &ordered_finalized_calls {
725        let tool_result_message = create_tool_result_message(finalized);
726        let msg = Message::ToolResult(tool_result_message.clone());
727        emit(AgentEvent::MessageStart {
728            message: msg.clone(),
729        });
730        emit(AgentEvent::MessageEnd { message: msg });
731        tool_result_messages.push(tool_result_message);
732    }
733
734    Ok(ExecutedToolCallBatch {
735        messages: tool_result_messages,
736        terminate: should_terminate_batch(&ordered_finalized_calls),
737    })
738}
739
740pub(crate) async fn execute_prepared_tool_call_static(
741    tool_call: ToolCall,
742    tool: Option<Arc<dyn crate::tools::AgentTool>>,
743    args: serde_json::Value,
744    after_hook: Option<AfterToolCallHook>,
745    emit: Arc<dyn Fn(AgentEvent) + Send + Sync>,
746    ctx: &ToolExecContext,
747    cancel_notify: Option<Arc<Notify>>,
748) -> ExecutedToolCallOutcome {
749    let tool_call_id = tool_call.id.clone();
750    let tool_name = tool_call.name.clone();
751    let static_intent = tool.as_ref().and_then(|t| Some(t.intent()?.to_string()));
752
753    let mut result = AgentToolResult::success("");
754    let mut is_error = false;
755
756    if let Some(ref tool) = tool {
757        // Infer semantic context — same as sequential path.
758        let context = infer_context(&tool_name, &args);
759
760        // Shared context cell for progressive enrichment.
761        let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
762            Arc::new(parking_lot::Mutex::new(context));
763
764        // Tab ID slot.
765        let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
766            Arc::new(parking_lot::Mutex::new(None));
767        tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
768
769        // String progress callback.
770        let emit_for_cb = emit.clone();
771        let tcid = tool_call_id.clone();
772        let tn = tool_name.clone();
773        let cc = Arc::clone(&context_cell);
774        let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
775            let tab_id = *tab_id_slot.lock();
776            let ctx = cc.lock().clone();
777            emit_for_cb(AgentEvent::ToolExecutionUpdate {
778                tool_call_id: tcid.clone(),
779                tool_name: tn.clone(),
780                partial_result: msg,
781                tab_id,
782                context: ctx,
783            });
784        });
785        tool.on_progress(progress_callback(move |msg: String| {
786            progress_cb(msg);
787        }));
788
789        // Browse progress callback — enriches context cell.
790        tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
791        // F-8 (audit 2026-06-21): see the matching note in
792        // `execute_prepared_tool_call`. Same `select!`-based cancellation
793        // wrap, same trade-off — additive, no trait change.
794        let exec_fut = tool.execute(&tool_call_id, args, None, ctx);
795        tokio::pin!(exec_fut);
796        let exec_result: Result<AgentToolResult, String> = match cancel_notify {
797            Some(notify) => {
798                let notify_for_select = Arc::clone(&notify);
799                tokio::select! {
800                    r = &mut exec_fut => r,
801                    _ = notify_for_select.notified() => Err(format!(
802                        "tool '{}' cancelled by agent loop",
803                        tool_call_id
804                    )),
805                }
806            }
807            None => exec_fut.await,
808        };
809        match exec_result {
810            Ok(r) => result = r,
811            Err(e) => {
812                result = AgentToolResult::error(e);
813                is_error = true;
814            }
815        }
816
817        enrich_context_from_metadata(&context_cell, &result);
818    }
819
820    if let Some(hook) = &after_hook {
821        match hook(&tool_call.name, &result).await {
822            Ok(Some(modified)) => {
823                if let Some(details) = &modified.metadata {
824                    tracing::debug!(
825                        tool = %tool_call.name,
826                        details = %details,
827                        "after_tool_call hook returned details"
828                    );
829                }
830                result = modified;
831                is_error = !result.success;
832            }
833            Ok(None) => {}
834            Err(hook_err) => {
835                tracing::warn!(
836                    tool = %tool_call.name,
837                    error = %hook_err,
838                    "after_tool_call hook failed, creating error result"
839                );
840                result = AgentToolResult::error(format!("after_tool_call hook failed: {hook_err}"));
841                is_error = true;
842            }
843        }
844    }
845
846    let end_intent = result.intent.clone().or(static_intent);
847
848    emit(AgentEvent::ToolExecutionEnd {
849        tool_call_id: tool_call_id.clone(),
850        tool_name: tool_name.clone(),
851        intent: end_intent,
852        result: oxicode_ai::ToolResult {
853            tool_call_id,
854            content: result.output.clone(),
855            status: if is_error {
856                String::from("error")
857            } else {
858                String::from("success")
859            },
860        },
861        is_error,
862    });
863
864    ExecutedToolCallOutcome { result, is_error }
865}
866
867async fn prepare_tool_call(
868    loop_ref: &super::AgentLoop,
869    tool_call: &ToolCall,
870) -> PreparedToolCallOutcome {
871    let tool = match loop_ref.tools.get(&tool_call.name) {
872        Some(t) => t,
873        None => {
874            return PreparedToolCallOutcome {
875                _kind: PreparedToolCallKind::Immediate,
876                immediate_result: Some(AgentToolResult::error(format!(
877                    "Tool '{}' not found",
878                    tool_call.name
879                ))),
880                is_error: true,
881                tool: None,
882                tool_call: tool_call.clone(),
883                args: tool_call.arguments.clone(),
884            };
885        }
886    };
887
888    let validated_args = tool_call.arguments.clone();
889
890    if let Some(ref hook) = loop_ref.before_tool_call
891        && let Some(blocked) = hook(&tool_call.name, &validated_args).await.ok().flatten()
892    {
893        return PreparedToolCallOutcome {
894            _kind: PreparedToolCallKind::Immediate,
895            immediate_result: Some(blocked),
896            is_error: true,
897            tool: None,
898            tool_call: tool_call.clone(),
899            args: validated_args,
900        };
901    }
902
903    PreparedToolCallOutcome {
904        _kind: PreparedToolCallKind::Prepared,
905        immediate_result: None,
906        is_error: false,
907        tool: Some(Arc::clone(&tool)),
908        tool_call: tool_call.clone(),
909        args: validated_args,
910    }
911}
912
913async fn execute_prepared_tool_call(
914    loop_ref: &super::AgentLoop,
915    prepared: &PreparedToolCallOutcome,
916    emit: &super::EmitFn,
917    ctx: &ToolExecContext,
918) -> ExecutedToolCallOutcome {
919    let tool_call_id = prepared.tool_call.id.clone();
920    let tool_name = prepared.tool_call.name.clone();
921
922    let mut result = AgentToolResult::success("");
923    let mut is_error = false;
924
925    if let Some(ref tool) = prepared.tool {
926        let tool_call_id_clone = tool_call_id.clone();
927        let tool_name_clone = tool_name.clone();
928        let emit_clone = emit.clone();
929
930        // Infer semantic context from tool name + args.
931        let context = infer_context(&tool_name, &prepared.args);
932
933        // Shared mutable context cell. The String progress callback reads
934        // from here; the browse progress callback writes enriched fields.
935        let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
936            Arc::new(parking_lot::Mutex::new(context));
937
938        // Shared slot for the active tab ID. Tools that manage browser tabs
939        // (BrowseTool) populate this when they open a tab; the progress
940        // callback reads it to include `tab_id` in `ToolExecutionUpdate`.
941        let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
942            Arc::new(parking_lot::Mutex::new(None));
943
944        // Pass the slot to the tool so it can write the tab_id.
945        tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
946
947        // String progress callback — reads context from shared cell.
948        let tab_id_slot_cb = Arc::clone(&tab_id_slot);
949        let cc = Arc::clone(&context_cell);
950        let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
951            let tab_id = *tab_id_slot_cb.lock();
952            let ctx = cc.lock().clone();
953            emit_clone(AgentEvent::ToolExecutionUpdate {
954                tool_call_id: tool_call_id_clone.clone(),
955                tool_name: tool_name_clone.clone(),
956                partial_result: msg,
957                tab_id,
958                context: ctx,
959            });
960        });
961
962        // Wire up progress callback BEFORE execute — pi-mono: tool's onUpdate
963        tool.on_progress(progress_callback(move |msg: String| {
964            progress_cb(msg);
965        }));
966
967        // Browse progress callback — enriches context cell with structured data.
968        // Wire up browse progress callback.
969        tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
970
971        // F-8 (audit 2026-06-21): wrap tool execution in a `tokio::select!`
972        // against the loop's cancellation notify. Before this, every call
973        // site passed `None` for the `signal: Option<oneshot::Receiver<()>>`
974        // parameter, defeating the cancellation contract — a long-running
975        // tool (e.g. `bash` with a 30s sleep) would only observe a cancel
976        // flag on the next 500 ms poll cycle, *after* it had already done
977        // most of its work. With `select!`, the tool's `await` is dropped
978        // the instant `cancel_signal` flips, returning control to the loop
979        // within ~250 ms (the poll cadence in `make_cancellation`).
980        //
981        // The tool's own `signal` argument is still `None` here — that
982        // parameter would require a trait signature change. `select!`
983        // cancellation at the call site is the minimal, additive fix
984        // that preserves the existing trait contract while honoring
985        // cancellation at the *outer* await point. Tools that respect
986        // `ctx.cancelled` (most do) still observe the loop's stop flag
987        // through `ctx`; tools that ignore it now at least get their
988        // outer future cancelled promptly.
989        let cancel_notify = make_cancellation(loop_ref);
990        let cancel_for_select = Arc::clone(&cancel_notify);
991        let tool_call_id_for_exec = tool_call_id.clone();
992        let exec_fut = tool.execute(&tool_call_id_for_exec, prepared.args.clone(), None, ctx);
993        tokio::pin!(exec_fut);
994        let cancelled_msg = format!("tool '{}' cancelled by agent loop", tool_call_id_for_exec);
995        let cancelled_msg_for_select = cancelled_msg.clone();
996        let exec_result: Result<AgentToolResult, String> = tokio::select! {
997            r = &mut exec_fut => r,
998            _ = cancel_for_select.notified() => Err(cancelled_msg_for_select),
999        };
1000        match exec_result {
1001            Ok(r) => result = r,
1002            Err(e) => {
1003                result = AgentToolResult::error(e);
1004                is_error = true;
1005            }
1006        }
1007
1008        enrich_context_from_metadata(&context_cell, &result);
1009    }
1010
1011    ExecutedToolCallOutcome { result, is_error }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use serde_json::json;
1018
1019    #[test]
1020    fn infer_context_web_search() {
1021        let ctx = infer_context("web_search", &json!({ "query": "rust headless browser" }));
1022        assert!(matches!(
1023            ctx,
1024            Some(ToolCallContext::WebSearch { query, .. }) if query == "rust headless browser"
1025        ));
1026    }
1027
1028    #[test]
1029    fn infer_context_web_search_with_engine() {
1030        let ctx = infer_context("web_search", &json!({ "query": "rust", "engines": "bing" }));
1031        assert!(matches!(
1032            ctx,
1033            Some(ToolCallContext::WebSearch { engine: Some(e), .. }) if e == "bing"
1034        ));
1035    }
1036
1037    #[test]
1038    fn infer_context_browse() {
1039        let ctx = infer_context(
1040            "browse",
1041            &json!({ "url": "https://github.com/example/repo" }),
1042        );
1043        match ctx {
1044            Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1045                assert_eq!(url, "https://github.com/example/repo");
1046                assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1047            }
1048            other => panic!("expected PageVisit, got {:?}", other),
1049        }
1050    }
1051
1052    #[test]
1053    fn infer_context_browse_extract() {
1054        let ctx = infer_context(
1055            "browse_extract",
1056            &json!({ "url": "https://example.com", "selector": ".title" }),
1057        );
1058        match ctx {
1059            Some(ToolCallContext::DataExtraction { target, url, .. }) => {
1060                assert_eq!(target, ".title");
1061                assert_eq!(url.as_deref(), Some("https://example.com"));
1062            }
1063            other => panic!("expected DataExtraction, got {:?}", other),
1064        }
1065    }
1066
1067    #[test]
1068    fn infer_context_browse_session_goto() {
1069        let ctx = infer_context(
1070            "browse_session",
1071            &json!({ "action": "goto", "url": "https://example.com" }),
1072        );
1073        match ctx {
1074            Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1075                assert_eq!(url, "https://example.com");
1076                assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1077            }
1078            other => panic!("expected PageVisit, got {:?}", other),
1079        }
1080    }
1081
1082    #[test]
1083    fn infer_context_browse_session_click() {
1084        let ctx = infer_context(
1085            "browse_session",
1086            &json!({ "action": "click", "selector": "#btn" }),
1087        );
1088        match ctx {
1089            Some(ToolCallContext::SessionAction { action, url }) => {
1090                assert_eq!(action, "click");
1091                assert!(url.is_none());
1092            }
1093            other => panic!("expected SessionAction, got {:?}", other),
1094        }
1095    }
1096
1097    #[test]
1098    fn infer_context_browse_script_with_steps_array() {
1099        let ctx = infer_context(
1100            "browse_script",
1101            &json!({ "steps": [{"goto": "https://example.com"}, {"click": "#btn"}] }),
1102        );
1103        match ctx {
1104            Some(ToolCallContext::ScriptStep {
1105                current,
1106                total,
1107                step,
1108            }) => {
1109                assert_eq!(current, 0);
1110                assert_eq!(total, 2);
1111                assert_eq!(step, "starting");
1112            }
1113            other => panic!("expected ScriptStep, got {:?}", other),
1114        }
1115    }
1116
1117    #[test]
1118    fn infer_context_browse_script_empty() {
1119        let ctx = infer_context("browse_script", &json!({ "script": "" }));
1120        #[cfg(feature = "native-browser")]
1121        assert!(ctx.is_none());
1122        #[cfg(not(feature = "native-browser"))]
1123        assert!(ctx.is_none());
1124    }
1125
1126    #[test]
1127    fn infer_context_unknown_tool() {
1128        let ctx = infer_context("bash", &json!({ "command": "ls" }));
1129        assert!(ctx.is_none());
1130    }
1131
1132    #[test]
1133    fn infer_context_missing_args() {
1134        // browse without url → None
1135        let ctx = infer_context("browse", &json!({}));
1136        assert!(ctx.is_none());
1137
1138        // web_search without query → None
1139        let ctx = infer_context("web_search", &json!({}));
1140        assert!(ctx.is_none());
1141    }
1142
1143    #[test]
1144    fn tool_context_serde_roundtrip() {
1145        let contexts = vec![
1146            ToolCallContext::WebSearch {
1147                query: "test".into(),
1148                engine: Some("ddg".into()),
1149            },
1150            ToolCallContext::PageVisit {
1151                url: "https://example.com".into(),
1152                reason: Some(VisitReason::DirectNavigation),
1153                page_title: None,
1154                page_status: None,
1155                page_bytes: None,
1156                page_duration_ms: None,
1157                navigation_error: None,
1158                screenshot: None,
1159            },
1160            ToolCallContext::PageVisit {
1161                url: "https://example.com".into(),
1162                reason: Some(VisitReason::SearchResult { position: 3 }),
1163                page_title: None,
1164                page_status: None,
1165                page_bytes: None,
1166                page_duration_ms: None,
1167                navigation_error: None,
1168                screenshot: None,
1169            },
1170            ToolCallContext::PageVisit {
1171                url: "https://example.com".into(),
1172                reason: None,
1173                page_title: Some("Example Page".into()),
1174                page_status: Some(200),
1175                page_bytes: Some(12400),
1176                page_duration_ms: Some(245),
1177                navigation_error: None,
1178                screenshot: None,
1179            },
1180            ToolCallContext::DataExtraction {
1181                target: ".title".into(),
1182                url: Some("https://example.com".into()),
1183                result_count: None,
1184                page_status: None,
1185                page_duration_ms: None,
1186            },
1187            ToolCallContext::DataExtraction {
1188                target: ".items".into(),
1189                url: Some("https://shop.example.com/products".into()),
1190                result_count: Some(42),
1191                page_status: Some(200),
1192                page_duration_ms: Some(180),
1193            },
1194            ToolCallContext::SessionAction {
1195                action: "goto".into(),
1196                url: Some("https://example.com".into()),
1197            },
1198            ToolCallContext::ScriptStep {
1199                current: 3,
1200                total: 10,
1201                step: "clicking".into(),
1202            },
1203        ];
1204
1205        for ctx in &contexts {
1206            let json = serde_json::to_string(ctx).unwrap();
1207            let restored: ToolCallContext = serde_json::from_str(&json).unwrap();
1208            let json2 = serde_json::to_string(&restored).unwrap();
1209            assert_eq!(json, json2, "roundtrip failed for {:?}", ctx);
1210        }
1211    }
1212
1213    #[test]
1214    fn tool_execution_update_backward_compat() {
1215        // Old JSON without context field → deserializes with context: None
1216        let old_json = json!({
1217            "type": "toolExecutionUpdate",
1218            "tool_call_id": "call_123",
1219            "tool_name": "browse",
1220            "partial_result": "Loading...",
1221            "tab_id": null
1222        });
1223        let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1224        match event {
1225            crate::events::AgentEvent::ToolExecutionUpdate { context, .. } => {
1226                assert!(context.is_none());
1227            }
1228            other => panic!("expected ToolExecutionUpdate, got {:?}", other),
1229        }
1230    }
1231
1232    #[test]
1233    fn tool_execution_start_backward_compat() {
1234        // Old JSON without context field
1235        let old_json = json!({
1236            "type": "toolExecutionStart",
1237            "tool_call_id": "call_123",
1238            "tool_name": "browse",
1239            "args": { "url": "https://example.com" }
1240        });
1241        let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1242        match event {
1243            crate::events::AgentEvent::ToolExecutionStart { context, .. } => {
1244                assert!(context.is_none());
1245            }
1246            other => panic!("expected ToolExecutionStart, got {:?}", other),
1247        }
1248    }
1249
1250    #[test]
1251    fn browse_enrichment_callback_fills_page_visit() {
1252        use crate::tools::browse::BrowseProgress;
1253        use std::sync::Arc;
1254
1255        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1256            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1257                url: "https://example.com".into(),
1258                reason: Some(VisitReason::DirectNavigation),
1259                page_title: None,
1260                page_status: None,
1261                page_bytes: None,
1262                page_duration_ms: None,
1263                navigation_error: None,
1264                screenshot: None,
1265            })));
1266        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1267        cb(BrowseProgress::DocumentReady {
1268            url: "https://example.com/final".into(),
1269            title: "Example".into(),
1270            status: 200,
1271            bytes: 4096,
1272            duration_ms: 245,
1273        });
1274        let snapshot = cell.lock().clone();
1275        match snapshot {
1276            Some(ToolCallContext::PageVisit {
1277                url,
1278                page_title,
1279                page_status,
1280                page_bytes,
1281                page_duration_ms,
1282                ..
1283            }) => {
1284                assert_eq!(url, "https://example.com/final");
1285                assert_eq!(page_title.as_deref(), Some("Example"));
1286                assert_eq!(page_status, Some(200));
1287                assert_eq!(page_bytes, Some(4096));
1288                assert_eq!(page_duration_ms, Some(245));
1289            }
1290            other => panic!("expected PageVisit, got {:?}", other),
1291        }
1292    }
1293
1294    #[test]
1295    fn browse_enrichment_callback_fills_data_extraction() {
1296        use crate::tools::browse::BrowseProgress;
1297        use std::sync::Arc;
1298
1299        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1300            parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1301                target: ".item".into(),
1302                url: Some("https://shop.example.com".into()),
1303                result_count: None,
1304                page_status: None,
1305                page_duration_ms: None,
1306            })),
1307        );
1308        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1309        cb(BrowseProgress::DocumentReady {
1310            url: "https://shop.example.com".into(),
1311            title: "Shop".into(),
1312            status: 200,
1313            bytes: 8192,
1314            duration_ms: 180,
1315        });
1316        let snapshot = cell.lock().clone();
1317        match snapshot {
1318            Some(ToolCallContext::DataExtraction {
1319                page_status,
1320                page_duration_ms,
1321                ..
1322            }) => {
1323                assert_eq!(page_status, Some(200));
1324                assert_eq!(page_duration_ms, Some(180));
1325            }
1326            other => panic!("expected DataExtraction, got {:?}", other),
1327        }
1328    }
1329
1330    #[test]
1331    fn browse_enrichment_callback_no_op_for_mismatched() {
1332        use crate::tools::browse::BrowseProgress;
1333        use std::sync::Arc;
1334
1335        // DocumentReady + ScriptStep → no-op
1336        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1337            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::ScriptStep {
1338                current: 1,
1339                total: 5,
1340                step: "click".into(),
1341            })));
1342        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1343        cb(BrowseProgress::DocumentReady {
1344            url: "x".into(),
1345            title: "t".into(),
1346            status: 200,
1347            bytes: 0,
1348            duration_ms: 0,
1349        });
1350        // ScriptStep should be untouched
1351        assert!(matches!(
1352            cell.lock().as_ref(),
1353            Some(ToolCallContext::ScriptStep { .. })
1354        ));
1355    }
1356
1357    #[test]
1358    fn browse_enrichment_callback_fills_navigation_error() {
1359        use crate::tools::browse::BrowseProgress;
1360        use std::sync::Arc;
1361
1362        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1363            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1364                url: "https://example.com".into(),
1365                reason: Some(VisitReason::DirectNavigation),
1366                page_title: None,
1367                page_status: None,
1368                page_bytes: None,
1369                page_duration_ms: None,
1370                navigation_error: None,
1371                screenshot: None,
1372            })));
1373        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1374        cb(BrowseProgress::NavigationFailed {
1375            url: "https://example.com".into(),
1376            error: "connection refused".into(),
1377        });
1378        let snapshot = cell.lock().clone();
1379        match snapshot {
1380            Some(ToolCallContext::PageVisit {
1381                navigation_error, ..
1382            }) => {
1383                assert_eq!(navigation_error.as_deref(), Some("connection refused"));
1384            }
1385            other => panic!("expected PageVisit, got {:?}", other),
1386        }
1387    }
1388
1389    #[test]
1390    fn browse_enrichment_callback_fills_screenshot() {
1391        use crate::tools::browse::BrowseProgress;
1392        use std::sync::Arc;
1393
1394        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1395            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1396                url: "https://example.com".into(),
1397                reason: Some(VisitReason::DirectNavigation),
1398                page_title: None,
1399                page_status: None,
1400                page_bytes: None,
1401                page_duration_ms: None,
1402                navigation_error: None,
1403                screenshot: None,
1404            })));
1405        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1406        cb(BrowseProgress::ScreenshotCaptured {
1407            bytes: 2048,
1408            width: 800,
1409            duration_ms: 120,
1410        });
1411        let snapshot = cell.lock().clone();
1412        match snapshot {
1413            Some(ToolCallContext::PageVisit { screenshot, .. }) => {
1414                let meta = screenshot.expect("screenshot should be set");
1415                assert_eq!(meta.bytes, 2048);
1416                assert_eq!(meta.width, 800);
1417                assert_eq!(meta.duration_ms, 120);
1418            }
1419            other => panic!("expected PageVisit, got {:?}", other),
1420        }
1421    }
1422
1423    #[test]
1424    fn browse_enrichment_callback_navigation_failed_ignores_non_page_visit() {
1425        use crate::tools::browse::BrowseProgress;
1426        use std::sync::Arc;
1427
1428        // NavigationFailed + DataExtraction → no-op
1429        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1430            parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1431                target: ".title".into(),
1432                url: None,
1433                result_count: None,
1434                page_status: None,
1435                page_duration_ms: None,
1436            })),
1437        );
1438        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1439        cb(BrowseProgress::NavigationFailed {
1440            url: "https://example.com".into(),
1441            error: "timeout".into(),
1442        });
1443        assert!(matches!(
1444            cell.lock().as_ref(),
1445            Some(ToolCallContext::DataExtraction { .. })
1446        ));
1447    }
1448}