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        // Drop the progress callback registered above. `on_progress`
820        // stores it on the shared, long-lived tool instance (tools are
821        // reused across every turn); leaving the closure attached keeps
822        // its captured `emit` (and the mpsc `Sender<AgentEvent>` inside
823        // it) alive forever, which never lets the event channel close.
824        // A run whose forwarder thread blocks on `event_rx.recv()`
825        // forever wedges the single-consumer prompt queue — every later
826        // queued prompt sits undelivered. Re-registering a no-op drops
827        // the previous closure immediately.
828        tool.on_progress(progress_callback(|_| {}));
829    }
830
831    if let Some(hook) = &after_hook {
832        match hook(&tool_call.name, &result).await {
833            Ok(Some(modified)) => {
834                if let Some(details) = &modified.metadata {
835                    tracing::debug!(
836                        tool = %tool_call.name,
837                        details = %details,
838                        "after_tool_call hook returned details"
839                    );
840                }
841                result = modified;
842                is_error = !result.success;
843            }
844            Ok(None) => {}
845            Err(hook_err) => {
846                tracing::warn!(
847                    tool = %tool_call.name,
848                    error = %hook_err,
849                    "after_tool_call hook failed, creating error result"
850                );
851                result = AgentToolResult::error(format!("after_tool_call hook failed: {hook_err}"));
852                is_error = true;
853            }
854        }
855    }
856
857    let end_intent = result.intent.clone().or(static_intent);
858
859    emit(AgentEvent::ToolExecutionEnd {
860        tool_call_id: tool_call_id.clone(),
861        tool_name: tool_name.clone(),
862        intent: end_intent,
863        result: oxicode_ai::ToolResult {
864            tool_call_id,
865            content: result.output.clone(),
866            status: if is_error {
867                String::from("error")
868            } else {
869                String::from("success")
870            },
871        },
872        is_error,
873    });
874
875    ExecutedToolCallOutcome { result, is_error }
876}
877
878async fn prepare_tool_call(
879    loop_ref: &super::AgentLoop,
880    tool_call: &ToolCall,
881) -> PreparedToolCallOutcome {
882    let tool = match loop_ref.tools.get(&tool_call.name) {
883        Some(t) => t,
884        None => {
885            return PreparedToolCallOutcome {
886                _kind: PreparedToolCallKind::Immediate,
887                immediate_result: Some(AgentToolResult::error(format!(
888                    "Tool '{}' not found",
889                    tool_call.name
890                ))),
891                is_error: true,
892                tool: None,
893                tool_call: tool_call.clone(),
894                args: tool_call.arguments.clone(),
895            };
896        }
897    };
898
899    let validated_args = tool_call.arguments.clone();
900
901    if let Some(ref hook) = loop_ref.before_tool_call
902        && let Some(blocked) = hook(&tool_call.name, &validated_args).await.ok().flatten()
903    {
904        return PreparedToolCallOutcome {
905            _kind: PreparedToolCallKind::Immediate,
906            immediate_result: Some(blocked),
907            is_error: true,
908            tool: None,
909            tool_call: tool_call.clone(),
910            args: validated_args,
911        };
912    }
913
914    PreparedToolCallOutcome {
915        _kind: PreparedToolCallKind::Prepared,
916        immediate_result: None,
917        is_error: false,
918        tool: Some(Arc::clone(&tool)),
919        tool_call: tool_call.clone(),
920        args: validated_args,
921    }
922}
923
924async fn execute_prepared_tool_call(
925    loop_ref: &super::AgentLoop,
926    prepared: &PreparedToolCallOutcome,
927    emit: &super::EmitFn,
928    ctx: &ToolExecContext,
929) -> ExecutedToolCallOutcome {
930    let tool_call_id = prepared.tool_call.id.clone();
931    let tool_name = prepared.tool_call.name.clone();
932
933    let mut result = AgentToolResult::success("");
934    let mut is_error = false;
935
936    if let Some(ref tool) = prepared.tool {
937        let tool_call_id_clone = tool_call_id.clone();
938        let tool_name_clone = tool_name.clone();
939        let emit_clone = emit.clone();
940
941        // Infer semantic context from tool name + args.
942        let context = infer_context(&tool_name, &prepared.args);
943
944        // Shared mutable context cell. The String progress callback reads
945        // from here; the browse progress callback writes enriched fields.
946        let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
947            Arc::new(parking_lot::Mutex::new(context));
948
949        // Shared slot for the active tab ID. Tools that manage browser tabs
950        // (BrowseTool) populate this when they open a tab; the progress
951        // callback reads it to include `tab_id` in `ToolExecutionUpdate`.
952        let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
953            Arc::new(parking_lot::Mutex::new(None));
954
955        // Pass the slot to the tool so it can write the tab_id.
956        tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
957
958        // String progress callback — reads context from shared cell.
959        let tab_id_slot_cb = Arc::clone(&tab_id_slot);
960        let cc = Arc::clone(&context_cell);
961        let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
962            let tab_id = *tab_id_slot_cb.lock();
963            let ctx = cc.lock().clone();
964            emit_clone(AgentEvent::ToolExecutionUpdate {
965                tool_call_id: tool_call_id_clone.clone(),
966                tool_name: tool_name_clone.clone(),
967                partial_result: msg,
968                tab_id,
969                context: ctx,
970            });
971        });
972
973        // Wire up progress callback BEFORE execute — pi-mono: tool's onUpdate
974        tool.on_progress(progress_callback(move |msg: String| {
975            progress_cb(msg);
976        }));
977
978        // Browse progress callback — enriches context cell with structured data.
979        // Wire up browse progress callback.
980        tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
981
982        // F-8 (audit 2026-06-21): wrap tool execution in a `tokio::select!`
983        // against the loop's cancellation notify. Before this, every call
984        // site passed `None` for the `signal: Option<oneshot::Receiver<()>>`
985        // parameter, defeating the cancellation contract — a long-running
986        // tool (e.g. `bash` with a 30s sleep) would only observe a cancel
987        // flag on the next 500 ms poll cycle, *after* it had already done
988        // most of its work. With `select!`, the tool's `await` is dropped
989        // the instant `cancel_signal` flips, returning control to the loop
990        // within ~250 ms (the poll cadence in `make_cancellation`).
991        //
992        // The tool's own `signal` argument is still `None` here — that
993        // parameter would require a trait signature change. `select!`
994        // cancellation at the call site is the minimal, additive fix
995        // that preserves the existing trait contract while honoring
996        // cancellation at the *outer* await point. Tools that respect
997        // `ctx.cancelled` (most do) still observe the loop's stop flag
998        // through `ctx`; tools that ignore it now at least get their
999        // outer future cancelled promptly.
1000        let cancel_notify = make_cancellation(loop_ref);
1001        let cancel_for_select = Arc::clone(&cancel_notify);
1002        let tool_call_id_for_exec = tool_call_id.clone();
1003        let exec_fut = tool.execute(&tool_call_id_for_exec, prepared.args.clone(), None, ctx);
1004        tokio::pin!(exec_fut);
1005        let cancelled_msg = format!("tool '{}' cancelled by agent loop", tool_call_id_for_exec);
1006        let cancelled_msg_for_select = cancelled_msg.clone();
1007        let exec_result: Result<AgentToolResult, String> = tokio::select! {
1008            r = &mut exec_fut => r,
1009            _ = cancel_for_select.notified() => Err(cancelled_msg_for_select),
1010        };
1011        match exec_result {
1012            Ok(r) => result = r,
1013            Err(e) => {
1014                result = AgentToolResult::error(e);
1015                is_error = true;
1016            }
1017        }
1018
1019        enrich_context_from_metadata(&context_cell, &result);
1020
1021        // See the matching comment in `execute_prepared_tool_call_static`:
1022        // drop the progress closure (and its captured `emit`/`Sender`)
1023        // now instead of leaving it wired to the shared tool instance.
1024        tool.on_progress(progress_callback(|_| {}));
1025    }
1026
1027    ExecutedToolCallOutcome { result, is_error }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033    use serde_json::json;
1034
1035    #[test]
1036    fn infer_context_web_search() {
1037        let ctx = infer_context("web_search", &json!({ "query": "rust headless browser" }));
1038        assert!(matches!(
1039            ctx,
1040            Some(ToolCallContext::WebSearch { query, .. }) if query == "rust headless browser"
1041        ));
1042    }
1043
1044    #[test]
1045    fn infer_context_web_search_with_engine() {
1046        let ctx = infer_context("web_search", &json!({ "query": "rust", "engines": "bing" }));
1047        assert!(matches!(
1048            ctx,
1049            Some(ToolCallContext::WebSearch { engine: Some(e), .. }) if e == "bing"
1050        ));
1051    }
1052
1053    #[test]
1054    fn infer_context_browse() {
1055        let ctx = infer_context(
1056            "browse",
1057            &json!({ "url": "https://github.com/example/repo" }),
1058        );
1059        match ctx {
1060            Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1061                assert_eq!(url, "https://github.com/example/repo");
1062                assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1063            }
1064            other => panic!("expected PageVisit, got {:?}", other),
1065        }
1066    }
1067
1068    #[test]
1069    fn infer_context_browse_extract() {
1070        let ctx = infer_context(
1071            "browse_extract",
1072            &json!({ "url": "https://example.com", "selector": ".title" }),
1073        );
1074        match ctx {
1075            Some(ToolCallContext::DataExtraction { target, url, .. }) => {
1076                assert_eq!(target, ".title");
1077                assert_eq!(url.as_deref(), Some("https://example.com"));
1078            }
1079            other => panic!("expected DataExtraction, got {:?}", other),
1080        }
1081    }
1082
1083    #[test]
1084    fn infer_context_browse_session_goto() {
1085        let ctx = infer_context(
1086            "browse_session",
1087            &json!({ "action": "goto", "url": "https://example.com" }),
1088        );
1089        match ctx {
1090            Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1091                assert_eq!(url, "https://example.com");
1092                assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1093            }
1094            other => panic!("expected PageVisit, got {:?}", other),
1095        }
1096    }
1097
1098    #[test]
1099    fn infer_context_browse_session_click() {
1100        let ctx = infer_context(
1101            "browse_session",
1102            &json!({ "action": "click", "selector": "#btn" }),
1103        );
1104        match ctx {
1105            Some(ToolCallContext::SessionAction { action, url }) => {
1106                assert_eq!(action, "click");
1107                assert!(url.is_none());
1108            }
1109            other => panic!("expected SessionAction, got {:?}", other),
1110        }
1111    }
1112
1113    #[test]
1114    fn infer_context_browse_script_with_steps_array() {
1115        let ctx = infer_context(
1116            "browse_script",
1117            &json!({ "steps": [{"goto": "https://example.com"}, {"click": "#btn"}] }),
1118        );
1119        match ctx {
1120            Some(ToolCallContext::ScriptStep {
1121                current,
1122                total,
1123                step,
1124            }) => {
1125                assert_eq!(current, 0);
1126                assert_eq!(total, 2);
1127                assert_eq!(step, "starting");
1128            }
1129            other => panic!("expected ScriptStep, got {:?}", other),
1130        }
1131    }
1132
1133    #[test]
1134    fn infer_context_browse_script_empty() {
1135        let ctx = infer_context("browse_script", &json!({ "script": "" }));
1136        #[cfg(feature = "native-browser")]
1137        assert!(ctx.is_none());
1138        #[cfg(not(feature = "native-browser"))]
1139        assert!(ctx.is_none());
1140    }
1141
1142    #[test]
1143    fn infer_context_unknown_tool() {
1144        let ctx = infer_context("bash", &json!({ "command": "ls" }));
1145        assert!(ctx.is_none());
1146    }
1147
1148    #[test]
1149    fn infer_context_missing_args() {
1150        // browse without url → None
1151        let ctx = infer_context("browse", &json!({}));
1152        assert!(ctx.is_none());
1153
1154        // web_search without query → None
1155        let ctx = infer_context("web_search", &json!({}));
1156        assert!(ctx.is_none());
1157    }
1158
1159    #[test]
1160    fn tool_context_serde_roundtrip() {
1161        let contexts = vec![
1162            ToolCallContext::WebSearch {
1163                query: "test".into(),
1164                engine: Some("ddg".into()),
1165            },
1166            ToolCallContext::PageVisit {
1167                url: "https://example.com".into(),
1168                reason: Some(VisitReason::DirectNavigation),
1169                page_title: None,
1170                page_status: None,
1171                page_bytes: None,
1172                page_duration_ms: None,
1173                navigation_error: None,
1174                screenshot: None,
1175            },
1176            ToolCallContext::PageVisit {
1177                url: "https://example.com".into(),
1178                reason: Some(VisitReason::SearchResult { position: 3 }),
1179                page_title: None,
1180                page_status: None,
1181                page_bytes: None,
1182                page_duration_ms: None,
1183                navigation_error: None,
1184                screenshot: None,
1185            },
1186            ToolCallContext::PageVisit {
1187                url: "https://example.com".into(),
1188                reason: None,
1189                page_title: Some("Example Page".into()),
1190                page_status: Some(200),
1191                page_bytes: Some(12400),
1192                page_duration_ms: Some(245),
1193                navigation_error: None,
1194                screenshot: None,
1195            },
1196            ToolCallContext::DataExtraction {
1197                target: ".title".into(),
1198                url: Some("https://example.com".into()),
1199                result_count: None,
1200                page_status: None,
1201                page_duration_ms: None,
1202            },
1203            ToolCallContext::DataExtraction {
1204                target: ".items".into(),
1205                url: Some("https://shop.example.com/products".into()),
1206                result_count: Some(42),
1207                page_status: Some(200),
1208                page_duration_ms: Some(180),
1209            },
1210            ToolCallContext::SessionAction {
1211                action: "goto".into(),
1212                url: Some("https://example.com".into()),
1213            },
1214            ToolCallContext::ScriptStep {
1215                current: 3,
1216                total: 10,
1217                step: "clicking".into(),
1218            },
1219        ];
1220
1221        for ctx in &contexts {
1222            let json = serde_json::to_string(ctx).unwrap();
1223            let restored: ToolCallContext = serde_json::from_str(&json).unwrap();
1224            let json2 = serde_json::to_string(&restored).unwrap();
1225            assert_eq!(json, json2, "roundtrip failed for {:?}", ctx);
1226        }
1227    }
1228
1229    #[test]
1230    fn tool_execution_update_backward_compat() {
1231        // Old JSON without context field → deserializes with context: None
1232        let old_json = json!({
1233            "type": "toolExecutionUpdate",
1234            "tool_call_id": "call_123",
1235            "tool_name": "browse",
1236            "partial_result": "Loading...",
1237            "tab_id": null
1238        });
1239        let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1240        match event {
1241            crate::events::AgentEvent::ToolExecutionUpdate { context, .. } => {
1242                assert!(context.is_none());
1243            }
1244            other => panic!("expected ToolExecutionUpdate, got {:?}", other),
1245        }
1246    }
1247
1248    #[test]
1249    fn tool_execution_start_backward_compat() {
1250        // Old JSON without context field
1251        let old_json = json!({
1252            "type": "toolExecutionStart",
1253            "tool_call_id": "call_123",
1254            "tool_name": "browse",
1255            "args": { "url": "https://example.com" }
1256        });
1257        let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1258        match event {
1259            crate::events::AgentEvent::ToolExecutionStart { context, .. } => {
1260                assert!(context.is_none());
1261            }
1262            other => panic!("expected ToolExecutionStart, got {:?}", other),
1263        }
1264    }
1265
1266    #[test]
1267    fn browse_enrichment_callback_fills_page_visit() {
1268        use crate::tools::browse::BrowseProgress;
1269        use std::sync::Arc;
1270
1271        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1272            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1273                url: "https://example.com".into(),
1274                reason: Some(VisitReason::DirectNavigation),
1275                page_title: None,
1276                page_status: None,
1277                page_bytes: None,
1278                page_duration_ms: None,
1279                navigation_error: None,
1280                screenshot: None,
1281            })));
1282        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1283        cb(BrowseProgress::DocumentReady {
1284            url: "https://example.com/final".into(),
1285            title: "Example".into(),
1286            status: 200,
1287            bytes: 4096,
1288            duration_ms: 245,
1289        });
1290        let snapshot = cell.lock().clone();
1291        match snapshot {
1292            Some(ToolCallContext::PageVisit {
1293                url,
1294                page_title,
1295                page_status,
1296                page_bytes,
1297                page_duration_ms,
1298                ..
1299            }) => {
1300                assert_eq!(url, "https://example.com/final");
1301                assert_eq!(page_title.as_deref(), Some("Example"));
1302                assert_eq!(page_status, Some(200));
1303                assert_eq!(page_bytes, Some(4096));
1304                assert_eq!(page_duration_ms, Some(245));
1305            }
1306            other => panic!("expected PageVisit, got {:?}", other),
1307        }
1308    }
1309
1310    #[test]
1311    fn browse_enrichment_callback_fills_data_extraction() {
1312        use crate::tools::browse::BrowseProgress;
1313        use std::sync::Arc;
1314
1315        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1316            parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1317                target: ".item".into(),
1318                url: Some("https://shop.example.com".into()),
1319                result_count: None,
1320                page_status: None,
1321                page_duration_ms: None,
1322            })),
1323        );
1324        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1325        cb(BrowseProgress::DocumentReady {
1326            url: "https://shop.example.com".into(),
1327            title: "Shop".into(),
1328            status: 200,
1329            bytes: 8192,
1330            duration_ms: 180,
1331        });
1332        let snapshot = cell.lock().clone();
1333        match snapshot {
1334            Some(ToolCallContext::DataExtraction {
1335                page_status,
1336                page_duration_ms,
1337                ..
1338            }) => {
1339                assert_eq!(page_status, Some(200));
1340                assert_eq!(page_duration_ms, Some(180));
1341            }
1342            other => panic!("expected DataExtraction, got {:?}", other),
1343        }
1344    }
1345
1346    #[test]
1347    fn browse_enrichment_callback_no_op_for_mismatched() {
1348        use crate::tools::browse::BrowseProgress;
1349        use std::sync::Arc;
1350
1351        // DocumentReady + ScriptStep → no-op
1352        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1353            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::ScriptStep {
1354                current: 1,
1355                total: 5,
1356                step: "click".into(),
1357            })));
1358        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1359        cb(BrowseProgress::DocumentReady {
1360            url: "x".into(),
1361            title: "t".into(),
1362            status: 200,
1363            bytes: 0,
1364            duration_ms: 0,
1365        });
1366        // ScriptStep should be untouched
1367        assert!(matches!(
1368            cell.lock().as_ref(),
1369            Some(ToolCallContext::ScriptStep { .. })
1370        ));
1371    }
1372
1373    #[test]
1374    fn browse_enrichment_callback_fills_navigation_error() {
1375        use crate::tools::browse::BrowseProgress;
1376        use std::sync::Arc;
1377
1378        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1379            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1380                url: "https://example.com".into(),
1381                reason: Some(VisitReason::DirectNavigation),
1382                page_title: None,
1383                page_status: None,
1384                page_bytes: None,
1385                page_duration_ms: None,
1386                navigation_error: None,
1387                screenshot: None,
1388            })));
1389        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1390        cb(BrowseProgress::NavigationFailed {
1391            url: "https://example.com".into(),
1392            error: "connection refused".into(),
1393        });
1394        let snapshot = cell.lock().clone();
1395        match snapshot {
1396            Some(ToolCallContext::PageVisit {
1397                navigation_error, ..
1398            }) => {
1399                assert_eq!(navigation_error.as_deref(), Some("connection refused"));
1400            }
1401            other => panic!("expected PageVisit, got {:?}", other),
1402        }
1403    }
1404
1405    #[test]
1406    fn browse_enrichment_callback_fills_screenshot() {
1407        use crate::tools::browse::BrowseProgress;
1408        use std::sync::Arc;
1409
1410        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1411            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1412                url: "https://example.com".into(),
1413                reason: Some(VisitReason::DirectNavigation),
1414                page_title: None,
1415                page_status: None,
1416                page_bytes: None,
1417                page_duration_ms: None,
1418                navigation_error: None,
1419                screenshot: None,
1420            })));
1421        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1422        cb(BrowseProgress::ScreenshotCaptured {
1423            bytes: 2048,
1424            width: 800,
1425            duration_ms: 120,
1426        });
1427        let snapshot = cell.lock().clone();
1428        match snapshot {
1429            Some(ToolCallContext::PageVisit { screenshot, .. }) => {
1430                let meta = screenshot.expect("screenshot should be set");
1431                assert_eq!(meta.bytes, 2048);
1432                assert_eq!(meta.width, 800);
1433                assert_eq!(meta.duration_ms, 120);
1434            }
1435            other => panic!("expected PageVisit, got {:?}", other),
1436        }
1437    }
1438
1439    #[test]
1440    fn browse_enrichment_callback_navigation_failed_ignores_non_page_visit() {
1441        use crate::tools::browse::BrowseProgress;
1442        use std::sync::Arc;
1443
1444        // NavigationFailed + DataExtraction → no-op
1445        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1446            parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1447                target: ".title".into(),
1448                url: None,
1449                result_count: None,
1450                page_status: None,
1451                page_duration_ms: None,
1452            })),
1453        );
1454        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1455        cb(BrowseProgress::NavigationFailed {
1456            url: "https://example.com".into(),
1457            error: "timeout".into(),
1458        });
1459        assert!(matches!(
1460            cell.lock().as_ref(),
1461            Some(ToolCallContext::DataExtraction { .. })
1462        ));
1463    }
1464}