Skip to main content

everruns_engine/execution/
act_hooks.rs

1// Post-act hooks for ActAtom
2//
3// Decision: Hooks are pure functions that inspect ActResult and return
4// declarative PostActActions. ActAtom interprets them (event emission, etc).
5// This keeps hooks testable without mocking EventEmitter.
6//
7// Decision: Hooks set `waiting_for_tool_results` on ActResult so workers
8// see a single generic flag — they never need to know WHY the act paused.
9//
10// PostToolExecHook (EVE-222): async hooks that run after each individual tool
11// execution. Unlike PostActHook (runs once after all tools), these run per-tool
12// and can mutate the result (e.g. persist output to VFS, inject metadata).
13
14use crate::events::{EventContext, EventRequest, ToolCallRequestedData};
15use crate::tool_types::{
16    ASK_USER_TOOL_NAME, CONFIRM_URL_ELICITATION_TOOL, FORM_ELICITATION_CALL_ID_PREFIX,
17    FormElicitationRequired, MCP_ELICITATION_ARGUMENT, ToolCall, ToolDefinition, ToolResult,
18    UrlElicitationRequired,
19};
20use crate::{event_emitter::EventEmitter, tool_context::ToolContext};
21use async_trait::async_trait;
22pub(crate) use everruns_core::tool_hooks::{PostToolExecHook, PreToolUseDecision, PreToolUseHook};
23use serde_json::json;
24use std::sync::Arc;
25use uuid::Uuid;
26
27use super::ExecutionContext;
28use super::act::ActResult;
29
30/// Run every registered `PreToolUseHook` against `tool_call`. Hooks chain
31/// sequentially; the first `Block` aborts the chain and is returned. If
32/// every hook returns `Continue`, the final (potentially mutated)
33/// `ToolCall` is returned.
34pub(super) async fn run_pre_tool_use_hooks(
35    hooks: &[Arc<dyn PreToolUseHook>],
36    mut tool_call: ToolCall,
37    tool_def: &ToolDefinition,
38    context: &ToolContext,
39) -> PreToolUseDecision {
40    for hook in hooks {
41        match hook.before_exec(tool_call.clone(), tool_def, context).await {
42            PreToolUseDecision::Continue(updated) => {
43                tool_call = updated;
44            }
45            block @ PreToolUseDecision::Block { .. } => return block,
46        }
47    }
48    PreToolUseDecision::Continue(tool_call)
49}
50
51/// Execute post-tool-exec hooks on a single tool result.
52///
53/// Runs capability-contributed hooks first, then final (infrastructure) hooks.
54pub(super) async fn run_post_tool_exec_hooks(
55    hooks: &[Arc<dyn PostToolExecHook>],
56    final_hooks: &[Arc<dyn PostToolExecHook>],
57    tool_call: &ToolCall,
58    tool_def: &ToolDefinition,
59    result: &mut ToolResult,
60    context: &ToolContext,
61) {
62    for hook in hooks {
63        hook.after_exec(tool_call, tool_def, result, context).await;
64    }
65    for hook in final_hooks {
66        hook.after_exec(tool_call, tool_def, result, context).await;
67    }
68}
69
70// ============================================================================
71// OutputHardLimitHook (EVE-225)
72// ============================================================================
73
74/// Maximum tool result size in bytes before truncation (64 KiB).
75///
76/// THREAT[TM-AGENT-012]: large results consume context window, increase cost,
77/// and expand the prompt injection surface.
78const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
79
80const TRUNCATION_SUFFIX: &str =
81    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
82
83/// Infrastructure hook that enforces a hard 64 KiB ceiling on tool result text.
84///
85/// Always registered as a `final_post_tool_hook` in ActAtom — cannot be removed
86/// by capabilities. Runs after all capability-contributed hooks so that
87/// persistence hooks (EVE-222) can capture full output before truncation.
88///
89/// Head-truncation with UTF-8 safety: keeps the first N bytes (on a char
90/// boundary) and appends an LLM-actionable suffix.
91pub struct OutputHardLimitHook;
92
93impl OutputHardLimitHook {
94    /// Truncate `text` to `MAX_TOOL_RESULT_BYTES` with a UTF-8-safe cut.
95    fn truncate(text: String) -> String {
96        if text.len() <= MAX_TOOL_RESULT_BYTES {
97            return text;
98        }
99        let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
100        let mut end = content_budget;
101        while end > 0 && !text.is_char_boundary(end) {
102            end -= 1;
103        }
104        let mut truncated = text[..end].to_string();
105        truncated.push_str(TRUNCATION_SUFFIX);
106        truncated
107    }
108}
109
110#[async_trait]
111impl PostToolExecHook for OutputHardLimitHook {
112    async fn after_exec(
113        &self,
114        tool_call: &ToolCall,
115        _tool_def: &ToolDefinition,
116        result: &mut ToolResult,
117        _context: &ToolContext,
118    ) {
119        // Truncate the result JSON value if it exceeds the limit.
120        if let Some(val) = result.result.take() {
121            match val {
122                serde_json::Value::String(s) => {
123                    let original_len = s.len();
124                    let truncated = Self::truncate(s);
125                    if truncated.len() < original_len {
126                        tracing::warn!(
127                            tool_name = %tool_call.name,
128                            tool_call_id = %tool_call.id,
129                            result_bytes = original_len,
130                            limit = MAX_TOOL_RESULT_BYTES,
131                            "Tool result exceeded hard limit, truncated"
132                        );
133                    }
134                    result.result = Some(serde_json::Value::String(truncated));
135                }
136                other => {
137                    // Non-string JSON: serialize, check size, convert to
138                    // truncated string if over limit.
139                    let serialized = serde_json::to_string(&other).unwrap_or_default();
140                    if serialized.len() > MAX_TOOL_RESULT_BYTES {
141                        tracing::warn!(
142                            tool_name = %tool_call.name,
143                            tool_call_id = %tool_call.id,
144                            result_bytes = serialized.len(),
145                            limit = MAX_TOOL_RESULT_BYTES,
146                            "Tool result exceeded hard limit, truncated"
147                        );
148                        let truncated = Self::truncate(serialized);
149                        result.result = Some(serde_json::Value::String(truncated));
150                    } else {
151                        result.result = Some(other);
152                    }
153                }
154            }
155        }
156
157        // Also cap error messages (unlikely to be huge, but defense in depth).
158        if let Some(err) = result.error.take() {
159            if err.len() > MAX_TOOL_RESULT_BYTES {
160                tracing::warn!(
161                    tool_name = %tool_call.name,
162                    tool_call_id = %tool_call.id,
163                    result_bytes = err.len(),
164                    limit = MAX_TOOL_RESULT_BYTES,
165                    "Tool error exceeded hard limit, truncated"
166                );
167            }
168            result.error = Some(Self::truncate(err));
169        }
170
171        // Cap native image payloads too. These bypass `result.result` JSON size
172        // checks and are appended directly as ContentPart::Image later. Enforce
173        // both a per-image ceiling (no single image larger than the budget) and
174        // a cumulative budget (many smaller images cannot blow past it either).
175        if let Some(images) = result.images.as_mut() {
176            let original_count = images.len();
177            let mut cumulative = 0usize;
178            images.retain(|img| {
179                let len = img.base64.len();
180                if len > MAX_TOOL_RESULT_BYTES {
181                    return false;
182                }
183                match cumulative.checked_add(len) {
184                    Some(total) if total <= MAX_TOOL_RESULT_BYTES => {
185                        cumulative = total;
186                        true
187                    }
188                    _ => false,
189                }
190            });
191            let dropped = original_count.saturating_sub(images.len());
192            if dropped > 0 {
193                tracing::warn!(
194                    tool_name = %tool_call.name,
195                    tool_call_id = %tool_call.id,
196                    dropped_images = dropped,
197                    kept_images = images.len(),
198                    kept_bytes = cumulative,
199                    limit = MAX_TOOL_RESULT_BYTES,
200                    "Tool images exceeded hard limit and were dropped"
201                );
202            }
203            if images.is_empty() {
204                result.images = None;
205            }
206        }
207    }
208}
209
210// ============================================================================
211// PostActHook trait
212// ============================================================================
213
214/// Action a post-act hook wants ActAtom to perform.
215#[derive(Debug, Clone)]
216pub enum PostActAction {
217    /// Emit a `tool.call_requested` event with synthetic client-side tool calls.
218    EmitToolCallRequested {
219        tool_calls: Vec<ToolCall>,
220        tool_definitions: Vec<ToolDefinition>,
221    },
222}
223
224/// Hook that runs after ActAtom finishes executing tools.
225///
226/// Hooks inspect the completed results and may:
227/// - Set `waiting_for_tool_results` on `ActResult`
228/// - Return actions for ActAtom to execute (e.g. emit events)
229///
230/// Hooks are pure: they return declarative actions rather than
231/// touching the event emitter directly. This makes them trivially testable.
232pub trait PostActHook: Send + Sync {
233    /// Inspect completed results, optionally mutate the result and return actions.
234    fn on_completed(
235        &self,
236        result: &mut ActResult,
237        tool_definitions: &[ToolDefinition],
238    ) -> Vec<PostActAction>;
239}
240
241// ============================================================================
242// ConnectionSetupHook
243// ============================================================================
244
245/// Hook that detects tools requiring user connection setup and emits
246/// synthetic `setup_connection` tool calls so the client can prompt the user.
247///
248/// When any tool returns `connection_required`, this hook:
249/// 1. Sets `waiting_for_tool_results = true` on ActResult
250/// 2. Returns a `PostActAction::EmitToolCallRequested` with synthetic tool calls
251pub struct ConnectionSetupHook;
252
253impl PostActHook for ConnectionSetupHook {
254    fn on_completed(
255        &self,
256        result: &mut ActResult,
257        _tool_definitions: &[ToolDefinition],
258    ) -> Vec<PostActAction> {
259        let connections: Vec<crate::tool_types::ConnectionRequired> = result
260            .results
261            .iter()
262            .filter_map(|r| r.connection_required.clone())
263            .collect();
264        if connections.is_empty() {
265            return vec![];
266        }
267
268        result.waiting_for_tool_results = true;
269
270        let tool_calls: Vec<ToolCall> = connections
271            .iter()
272            .map(|required| {
273                let mut arguments = json!({ "provider": required.provider });
274                if let Some(subject) = required.subject {
275                    arguments["subject"] = json!(subject);
276                }
277                if let Some(setup_url) = required.setup_url.as_deref() {
278                    arguments["setup_url"] = json!(setup_url);
279                }
280                ToolCall {
281                    id: format!("setup_conn_{}", Uuid::now_v7()),
282                    name: "setup_connection".to_string(),
283                    arguments,
284                }
285            })
286            .collect();
287
288        vec![PostActAction::EmitToolCallRequested {
289            tool_calls,
290            tool_definitions: vec![],
291        }]
292    }
293}
294
295// ============================================================================
296// UrlElicitationHook
297// ============================================================================
298
299/// Hook that pauses the turn when an MCP tool stopped on a URL mode
300/// elicitation, and emits a synthetic `confirm_url_elicitation` call so the
301/// client can ask a human whether to open the URL.
302///
303/// The MCP client cannot answer such an elicitation on its own: the value the
304/// server wants is typed into someone's browser, not passed back through the
305/// client, and consent to open a link is a decision only a person can make.
306/// Pausing here is what turns "the model was handed a URL and mentions it in
307/// prose" into "the user is shown the domain and clicks".
308///
309/// The pause itself is still gated by the session's `setup_connection` hint
310/// (see `plan_after_act`): a client that cannot render the card keeps the old
311/// behaviour, where the elicitation is relayed to the user as an ordinary tool
312/// result and they re-run the tool themselves.
313pub struct UrlElicitationHook;
314
315impl PostActHook for UrlElicitationHook {
316    fn on_completed(
317        &self,
318        result: &mut ActResult,
319        _tool_definitions: &[ToolDefinition],
320    ) -> Vec<PostActAction> {
321        let pending: Vec<UrlElicitationRequired> = result
322            .results
323            .iter()
324            .filter_map(|r| UrlElicitationRequired::from_tool_result(&r.result))
325            // A refusal is a finished decision. Asking again in a card would
326            // nag the user for something they just said no to.
327            .filter(|elicitation| !elicitation.declined)
328            .collect();
329
330        if pending.is_empty() {
331            return vec![];
332        }
333
334        result.waiting_for_tool_results = true;
335        result.waiting_for_url_elicitation = true;
336
337        let tool_calls: Vec<ToolCall> = pending
338            .iter()
339            .map(|elicitation| ToolCall {
340                id: format!("url_elicitation_{}", Uuid::now_v7()),
341                name: CONFIRM_URL_ELICITATION_TOOL.to_string(),
342                // The whole elicitation travels in the arguments so the card can
343                // show the server, its reason, and the full URL with the domain
344                // highlighted, without re-reading the tool result.
345                arguments: json!({
346                    "server": elicitation.server,
347                    "tool": elicitation.tool,
348                    "retry_tool": elicitation.retry_tool,
349                    "message": elicitation.message,
350                    "url": elicitation.url,
351                    "url_host": elicitation.url_host,
352                    "url_is_punycode": elicitation.url_is_punycode,
353                }),
354            })
355            .collect();
356
357        vec![PostActAction::EmitToolCallRequested {
358            tool_calls,
359            tool_definitions: vec![],
360        }]
361    }
362}
363
364/// Whether ActAtom executes `call` itself. Client-side tools wait for the
365/// client. So does a provider's remote MCP approval request: it has no tool
366/// definition, and a person answers it through the same tool-results path.
367/// Any other unknown tool goes to the server, which reports it.
368pub(super) fn runs_on_server(call: &ToolCall, tool_definitions: &[ToolDefinition]) -> bool {
369    match tool_definitions.iter().find(|td| td.name() == call.name) {
370        Some(td) => !matches!(td, ToolDefinition::ClientSide(_)),
371        None => call.name != everruns_provider::openai_hosted_tools::OPENAI_MCP_APPROVAL_TOOL,
372    }
373}
374
375// ============================================================================
376// FormElicitationHook
377// ============================================================================
378
379/// Seconds a person gets to answer an MCP server's form questions before the
380/// sweep declines on their behalf. Matches the `ask_user` default.
381const FORM_ELICITATION_TIMEOUT_SECONDS: i64 = 300;
382/// How long before the deadline the card starts warning. Matches `ask_user`.
383const FORM_ELICITATION_NUDGE_LEAD_SECONDS: i64 = 60;
384
385/// Hook that turns an MCP server's form mode elicitation into an `ask_user`
386/// question set the person answers in the usual card.
387///
388/// Spec: knowledge/integrations/mcp-form-elicitation.md.
389///
390/// Decision: the call is appended to `client_tool_calls` instead of being
391/// emitted here, so it rides the `ask_user` pause exactly: `ClientSideToolHook`
392/// emits it, the planner gates the pause on the `ask_user` hint, and a client
393/// that cannot draw a question gets the unattended resolution, which declines
394/// an elicitation rather than applying defaults (D5). Must run before
395/// `ClientSideToolHook`.
396///
397/// The call id carries [`FORM_ELICITATION_CALL_ID_PREFIX`] and the arguments
398/// carry [`MCP_ELICITATION_ARGUMENT`], which is how the answer path knows the
399/// questions are a server's and not the model's.
400pub struct FormElicitationHook;
401
402impl PostActHook for FormElicitationHook {
403    fn on_completed(
404        &self,
405        result: &mut ActResult,
406        _tool_definitions: &[ToolDefinition],
407    ) -> Vec<PostActAction> {
408        let pending: Vec<FormElicitationRequired> = result
409            .results
410            .iter()
411            .filter_map(|r| FormElicitationRequired::from_tool_result(&r.result))
412            .collect();
413        if pending.is_empty() {
414            return vec![];
415        }
416
417        let asked_at = chrono::Utc::now();
418        let expires_at = asked_at + chrono::Duration::seconds(FORM_ELICITATION_TIMEOUT_SECONDS);
419        let nudge_at = expires_at - chrono::Duration::seconds(FORM_ELICITATION_NUDGE_LEAD_SECONDS);
420
421        for elicitation in pending {
422            result.client_tool_calls.push(ToolCall {
423                id: format!("{FORM_ELICITATION_CALL_ID_PREFIX}{}", Uuid::now_v7()),
424                name: ASK_USER_TOOL_NAME.to_string(),
425                arguments: json!({
426                    "questions": elicitation.questions,
427                    "timeout_seconds": FORM_ELICITATION_TIMEOUT_SECONDS,
428                    "asked_at": asked_at.to_rfc3339(),
429                    "nudge_at": nudge_at.to_rfc3339(),
430                    "expires_at": expires_at.to_rfc3339(),
431                    MCP_ELICITATION_ARGUMENT: {
432                        "server": elicitation.server,
433                        "tool": elicitation.tool,
434                        "retry_tool": elicitation.retry_tool,
435                        "message": elicitation.message,
436                        "fingerprint": elicitation.fingerprint,
437                    },
438                }),
439            });
440        }
441        vec![]
442    }
443}
444
445// ============================================================================
446// ClientSideToolHook
447// ============================================================================
448
449/// Hook that handles client-side tool calls from the ReasonResult.
450///
451/// When ActAtom receives tool calls that include client-side tools,
452/// those tools are NOT executed (they're filtered out before execution).
453/// Instead, this hook emits `tool.call_requested` events so the client
454/// can execute them and return results.
455///
456/// This hook reads client-side tool calls stored on ActResult by ActAtom's
457/// partitioning logic, then emits the appropriate event.
458pub struct ClientSideToolHook;
459
460impl PostActHook for ClientSideToolHook {
461    fn on_completed(
462        &self,
463        result: &mut ActResult,
464        _tool_definitions: &[ToolDefinition],
465    ) -> Vec<PostActAction> {
466        if result.client_tool_calls.is_empty() {
467            return vec![];
468        }
469
470        result.waiting_for_tool_results = true;
471
472        vec![PostActAction::EmitToolCallRequested {
473            tool_calls: result.client_tool_calls.clone(),
474            tool_definitions: result.client_tool_definitions.clone(),
475        }]
476    }
477}
478
479// ============================================================================
480// Hook execution helper
481// ============================================================================
482
483/// Execute all post-act hooks and apply their actions.
484///
485/// This is called by ActAtom after tool execution completes. It:
486/// 1. Runs each hook to collect actions
487/// 2. Emits events for each action
488pub(super) async fn run_post_act_hooks<E: EventEmitter>(
489    hooks: &[Box<dyn PostActHook>],
490    context: &ExecutionContext,
491    result: &mut ActResult,
492    tool_definitions: &[ToolDefinition],
493    event_emitter: &E,
494    locale: Option<&str>,
495) {
496    for hook in hooks {
497        let actions = hook.on_completed(result, tool_definitions);
498        for action in actions {
499            match action {
500                PostActAction::EmitToolCallRequested {
501                    tool_calls,
502                    tool_definitions: action_defs,
503                } => {
504                    let event = EventRequest::new(
505                        context.session_id,
506                        EventContext::from_execution_context(context),
507                        ToolCallRequestedData::with_definitions_and_locale(
508                            &tool_calls,
509                            &action_defs,
510                            locale,
511                        ),
512                    );
513                    if let Err(e) = event_emitter.emit(event).await {
514                        tracing::warn!(
515                            error = %e,
516                            "PostActHook: failed to emit tool.call_requested event"
517                        );
518                    }
519                }
520            }
521        }
522    }
523}
524
525// ============================================================================
526// Tests
527// ============================================================================
528
529#[cfg(test)]
530mod tests {
531
532    #[test]
533    fn provider_approval_waits_for_the_client_but_other_unknown_tools_do_not() {
534        let call = |name: &str| ToolCall {
535            id: "c".into(),
536            name: name.into(),
537            arguments: json!({}),
538        };
539        let approval = everruns_provider::openai_hosted_tools::OPENAI_MCP_APPROVAL_TOOL;
540        assert!(!runs_on_server(&call(approval), &[]));
541        assert!(runs_on_server(&call("missing_tool"), &[]));
542    }
543
544    use super::*;
545    use crate::execution::act::ToolCallResult;
546    use crate::tool_types::{ConnectionRequired, ConnectionRequiredSubject, ToolResult};
547    use std::sync::Mutex;
548
549    fn make_tool_call_result(connection_required: Option<&str>) -> ToolCallResult {
550        ToolCallResult {
551            tool_call: ToolCall {
552                id: "call_1".to_string(),
553                name: "some_tool".to_string(),
554                arguments: json!({}),
555            },
556            result: ToolResult {
557                tool_call_id: "call_1".to_string(),
558                result: Some(json!({})),
559                images: None,
560                error: None,
561                connection_required: connection_required.map(ConnectionRequired::provider_only),
562                raw_output: None,
563            },
564            success: true,
565            status: "success".to_string(),
566            connection_required: connection_required.map(ConnectionRequired::provider_only),
567            determinism_fatal: None,
568        }
569    }
570
571    #[test]
572    fn test_connection_setup_hook_no_connections() {
573        let hook = ConnectionSetupHook;
574        let mut result = ActResult {
575            results: vec![make_tool_call_result(None)],
576            completed: true,
577            success_count: 1,
578            error_count: 0,
579            waiting_for_tool_results: false,
580            waiting_for_url_elicitation: false,
581            blocked: false,
582            client_tool_calls: vec![],
583            client_tool_definitions: vec![],
584        };
585
586        let actions = hook.on_completed(&mut result, &[]);
587        assert!(actions.is_empty());
588        assert!(!result.waiting_for_tool_results);
589    }
590
591    #[test]
592    fn test_connection_setup_hook_with_connection() {
593        let hook = ConnectionSetupHook;
594        let mut result = ActResult {
595            results: vec![make_tool_call_result(Some("github"))],
596            completed: true,
597            success_count: 0,
598            error_count: 0,
599            waiting_for_tool_results: false,
600            waiting_for_url_elicitation: false,
601            blocked: false,
602            client_tool_calls: vec![],
603            client_tool_definitions: vec![],
604        };
605
606        let actions = hook.on_completed(&mut result, &[]);
607        assert_eq!(actions.len(), 1);
608        assert!(result.waiting_for_tool_results);
609
610        match &actions[0] {
611            PostActAction::EmitToolCallRequested { tool_calls, .. } => {
612                assert_eq!(tool_calls.len(), 1);
613                assert_eq!(tool_calls[0].name, "setup_connection");
614                assert_eq!(tool_calls[0].arguments["provider"], "github");
615            }
616        }
617    }
618
619    #[test]
620    fn connection_setup_hook_preserves_subject_and_setup_url() {
621        let required = ConnectionRequired::with_setup(
622            "mcp_oauth_linear",
623            ConnectionRequiredSubject::Agent,
624            "/agents/agent_123?tab=mcp",
625        );
626        let mut call_result = make_tool_call_result(None);
627        call_result.result.connection_required = Some(required.clone());
628        call_result.connection_required = Some(required);
629        let mut result = ActResult {
630            results: vec![call_result],
631            completed: true,
632            success_count: 0,
633            error_count: 0,
634            waiting_for_tool_results: false,
635            waiting_for_url_elicitation: false,
636            blocked: false,
637            client_tool_calls: vec![],
638            client_tool_definitions: vec![],
639        };
640
641        let actions = ConnectionSetupHook.on_completed(&mut result, &[]);
642
643        match &actions[0] {
644            PostActAction::EmitToolCallRequested { tool_calls, .. } => {
645                assert_eq!(
646                    tool_calls[0].arguments,
647                    json!({
648                        "provider": "mcp_oauth_linear",
649                        "subject": "agent",
650                        "setup_url": "/agents/agent_123?tab=mcp",
651                    })
652                );
653            }
654        }
655    }
656
657    fn make_elicitation_result(declined: bool) -> ToolCallResult {
658        let payload = UrlElicitationRequired {
659            code: crate::tool_types::URL_ELICITATION_REQUIRED_CODE.to_string(),
660            error: "needs a person".to_string(),
661            url: "https://pay.example.com/authorize/42".to_string(),
662            url_host: "pay.example.com".to_string(),
663            url_is_punycode: false,
664            server: "billing".to_string(),
665            tool: "charge".to_string(),
666            retry_tool: "mcp_billing_charge".to_string(),
667            message: "Authorize the charge".to_string(),
668            declined,
669        };
670        ToolCallResult {
671            tool_call: ToolCall {
672                id: "call_1".to_string(),
673                name: "mcp_billing_charge".to_string(),
674                arguments: json!({}),
675            },
676            result: ToolResult {
677                tool_call_id: "call_1".to_string(),
678                result: Some(serde_json::to_value(&payload).expect("serialize")),
679                images: None,
680                error: None,
681                connection_required: None,
682                raw_output: None,
683            },
684            success: true,
685            status: "success".to_string(),
686            connection_required: None,
687            determinism_fatal: None,
688        }
689    }
690
691    fn act_result(results: Vec<ToolCallResult>) -> ActResult {
692        ActResult {
693            results,
694            completed: true,
695            success_count: 1,
696            error_count: 0,
697            waiting_for_tool_results: false,
698            waiting_for_url_elicitation: false,
699            blocked: false,
700            client_tool_calls: vec![],
701            client_tool_definitions: vec![],
702        }
703    }
704
705    #[test]
706    fn url_elicitation_hook_pauses_and_asks_for_consent() {
707        let mut result = act_result(vec![make_elicitation_result(false)]);
708
709        let actions = UrlElicitationHook.on_completed(&mut result, &[]);
710
711        assert!(
712            result.waiting_for_tool_results,
713            "the turn must hold while a human decides"
714        );
715        assert_eq!(actions.len(), 1);
716        match &actions[0] {
717            PostActAction::EmitToolCallRequested { tool_calls, .. } => {
718                assert_eq!(tool_calls.len(), 1);
719                assert_eq!(tool_calls[0].name, CONFIRM_URL_ELICITATION_TOOL);
720                let arguments = &tool_calls[0].arguments;
721                // The card needs the full URL and the domain to highlight.
722                assert_eq!(arguments["url"], "https://pay.example.com/authorize/42");
723                assert_eq!(arguments["url_host"], "pay.example.com");
724                assert_eq!(arguments["server"], "billing");
725                assert_eq!(arguments["tool"], "charge");
726                assert_eq!(arguments["retry_tool"], "mcp_billing_charge");
727                assert_eq!(arguments["message"], "Authorize the charge");
728                assert_eq!(arguments["url_is_punycode"], false);
729            }
730        }
731    }
732
733    #[test]
734    fn url_elicitation_hook_does_not_re_ask_after_a_refusal() {
735        let mut result = act_result(vec![make_elicitation_result(true)]);
736
737        let actions = UrlElicitationHook.on_completed(&mut result, &[]);
738
739        assert!(actions.is_empty());
740        assert!(
741            !result.waiting_for_tool_results,
742            "a refusal is a decision; the turn continues"
743        );
744    }
745
746    #[test]
747    fn url_elicitation_hook_ignores_ordinary_results() {
748        let mut result = act_result(vec![make_tool_call_result(None)]);
749
750        let actions = UrlElicitationHook.on_completed(&mut result, &[]);
751
752        assert!(actions.is_empty());
753        assert!(!result.waiting_for_tool_results);
754    }
755
756    fn make_form_elicitation_result() -> ToolCallResult {
757        let payload = FormElicitationRequired {
758            code: crate::tool_types::FORM_ELICITATION_REQUIRED_CODE.to_string(),
759            error: "The server needs answers".to_string(),
760            server: "deploys".to_string(),
761            tool: "release".to_string(),
762            retry_tool: "mcp_deploys_release".to_string(),
763            message: "Which environment?".to_string(),
764            questions: vec![json!({"kind": "choice", "id": "environment"})],
765            fingerprint: "abc123".to_string(),
766        };
767        let mut result = make_tool_call_result(None);
768        result.result.result = Some(serde_json::to_value(&payload).expect("serialize"));
769        result
770    }
771
772    #[test]
773    fn form_elicitation_hook_asks_through_the_ask_user_pause() {
774        let mut result = act_result(vec![make_form_elicitation_result()]);
775
776        let actions = FormElicitationHook.on_completed(&mut result, &[]);
777        assert!(actions.is_empty(), "ClientSideToolHook emits the call");
778        assert_eq!(result.client_tool_calls.len(), 1);
779        let call = &result.client_tool_calls[0];
780        assert_eq!(call.name, ASK_USER_TOOL_NAME);
781        assert!(call.id.starts_with(FORM_ELICITATION_CALL_ID_PREFIX));
782        let elicitation = &call.arguments[MCP_ELICITATION_ARGUMENT];
783        assert_eq!(elicitation["server"], "deploys");
784        assert_eq!(elicitation["tool"], "release");
785        assert_eq!(elicitation["retry_tool"], "mcp_deploys_release");
786        assert_eq!(elicitation["fingerprint"], "abc123");
787        assert_eq!(call.arguments["questions"][0]["id"], "environment");
788        assert!(call.arguments["expires_at"].is_string());
789
790        // The follow-on hook is what pauses the turn and emits the card.
791        let actions = ClientSideToolHook.on_completed(&mut result, &[]);
792        assert_eq!(actions.len(), 1);
793        assert!(result.waiting_for_tool_results);
794    }
795
796    #[test]
797    fn form_elicitation_hook_ignores_ordinary_results() {
798        let mut result = act_result(vec![make_tool_call_result(None)]);
799
800        FormElicitationHook.on_completed(&mut result, &[]);
801
802        assert!(result.client_tool_calls.is_empty());
803    }
804
805    #[test]
806    fn test_client_side_tool_hook_no_client_tools() {
807        let hook = ClientSideToolHook;
808        let mut result = ActResult {
809            results: vec![],
810            completed: true,
811            success_count: 0,
812            error_count: 0,
813            waiting_for_tool_results: false,
814            waiting_for_url_elicitation: false,
815            blocked: false,
816            client_tool_calls: vec![],
817            client_tool_definitions: vec![],
818        };
819
820        let actions = hook.on_completed(&mut result, &[]);
821        assert!(actions.is_empty());
822        assert!(!result.waiting_for_tool_results);
823    }
824
825    #[test]
826    fn test_client_side_tool_hook_with_client_tools() {
827        let hook = ClientSideToolHook;
828        let client_call = ToolCall {
829            id: "call_client".to_string(),
830            name: "browser_click".to_string(),
831            arguments: json!({"selector": "#btn"}),
832        };
833
834        let mut result = ActResult {
835            results: vec![],
836            completed: true,
837            success_count: 0,
838            error_count: 0,
839            waiting_for_tool_results: false,
840            waiting_for_url_elicitation: false,
841            blocked: false,
842            client_tool_calls: vec![client_call.clone()],
843            client_tool_definitions: vec![],
844        };
845
846        let actions = hook.on_completed(&mut result, &[]);
847        assert_eq!(actions.len(), 1);
848        assert!(result.waiting_for_tool_results);
849
850        match &actions[0] {
851            PostActAction::EmitToolCallRequested { tool_calls, .. } => {
852                assert_eq!(tool_calls.len(), 1);
853                assert_eq!(tool_calls[0].name, "browser_click");
854            }
855        }
856    }
857
858    // ========================================================================
859    // OutputHardLimitHook tests (EVE-225)
860    // ========================================================================
861
862    use crate::tool_context::ToolContext;
863    use crate::typed_id::SessionId;
864
865    fn make_tool_call() -> ToolCall {
866        ToolCall {
867            id: "call_test".to_string(),
868            name: "test_tool".to_string(),
869            arguments: json!({}),
870        }
871    }
872
873    fn make_tool_def() -> ToolDefinition {
874        ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
875            name: "test_tool".to_string(),
876            display_name: None,
877            description: "test".to_string(),
878            parameters: json!({}),
879            policy: crate::tool_types::ToolPolicy::Auto,
880            category: None,
881            deferrable: crate::tool_types::DeferrablePolicy::Never,
882            hints: Default::default(),
883            full_parameters: None,
884        })
885    }
886
887    struct MarkerHook {
888        name: &'static str,
889        calls: Arc<Mutex<Vec<&'static str>>>,
890    }
891
892    #[async_trait]
893    impl PostToolExecHook for MarkerHook {
894        async fn after_exec(
895            &self,
896            _tool_call: &ToolCall,
897            _tool_def: &ToolDefinition,
898            result: &mut ToolResult,
899            _context: &ToolContext,
900        ) {
901            self.calls.lock().unwrap().push(self.name);
902            let value = result
903                .result
904                .take()
905                .and_then(|value| value.as_str().map(str::to_owned))
906                .unwrap_or_default();
907            result.result = Some(json!(format!("{value}-{}", self.name)));
908        }
909    }
910
911    #[tokio::test]
912    async fn capability_hooks_run_before_runtime_final_hooks() {
913        let calls = Arc::new(Mutex::new(Vec::new()));
914        let capability_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
915            name: "capability",
916            calls: Arc::clone(&calls),
917        })];
918        let final_hooks: Vec<Arc<dyn PostToolExecHook>> = vec![Arc::new(MarkerHook {
919            name: "final",
920            calls: Arc::clone(&calls),
921        })];
922        let mut result = ToolResult {
923            tool_call_id: "call_test".into(),
924            result: Some(json!("start")),
925            images: None,
926            error: None,
927            connection_required: None,
928            raw_output: None,
929        };
930
931        run_post_tool_exec_hooks(
932            &capability_hooks,
933            &final_hooks,
934            &make_tool_call(),
935            &make_tool_def(),
936            &mut result,
937            &ToolContext::new(SessionId::new()),
938        )
939        .await;
940
941        assert_eq!(*calls.lock().unwrap(), ["capability", "final"]);
942        assert_eq!(result.result, Some(json!("start-capability-final")));
943    }
944
945    #[tokio::test]
946    async fn test_output_hard_limit_passthrough_small() {
947        let hook = OutputHardLimitHook;
948        let tc = make_tool_call();
949        let td = make_tool_def();
950        let ctx = ToolContext::new(SessionId::new());
951        let mut result = ToolResult {
952            tool_call_id: "call_test".into(),
953            result: Some(json!("hello")),
954            images: None,
955            error: None,
956            connection_required: None,
957            raw_output: None,
958        };
959
960        hook.after_exec(&tc, &td, &mut result, &ctx).await;
961        assert_eq!(result.result, Some(json!("hello")));
962    }
963
964    #[tokio::test]
965    async fn test_output_hard_limit_truncates_large_string() {
966        let hook = OutputHardLimitHook;
967        let tc = make_tool_call();
968        let td = make_tool_def();
969        let ctx = ToolContext::new(SessionId::new());
970        let big = "x".repeat(MAX_TOOL_RESULT_BYTES + 1000);
971        let mut result = ToolResult {
972            tool_call_id: "call_test".into(),
973            result: Some(json!(big)),
974            images: None,
975            error: None,
976            connection_required: None,
977            raw_output: None,
978        };
979
980        hook.after_exec(&tc, &td, &mut result, &ctx).await;
981
982        let text = result.result.unwrap();
983        let s = text.as_str().unwrap();
984        assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
985        assert!(s.ends_with(TRUNCATION_SUFFIX));
986    }
987
988    #[tokio::test]
989    async fn test_output_hard_limit_at_exact_limit() {
990        let hook = OutputHardLimitHook;
991        let tc = make_tool_call();
992        let td = make_tool_def();
993        let ctx = ToolContext::new(SessionId::new());
994        let exact = "a".repeat(MAX_TOOL_RESULT_BYTES);
995        let mut result = ToolResult {
996            tool_call_id: "call_test".into(),
997            result: Some(json!(exact)),
998            images: None,
999            error: None,
1000            connection_required: None,
1001            raw_output: None,
1002        };
1003
1004        hook.after_exec(&tc, &td, &mut result, &ctx).await;
1005
1006        let text = result.result.unwrap();
1007        let s = text.as_str().unwrap();
1008        // Should NOT be truncated (equal to limit)
1009        assert_eq!(s.len(), MAX_TOOL_RESULT_BYTES);
1010        assert!(!s.contains("[Output truncated"));
1011    }
1012
1013    #[tokio::test]
1014    async fn test_output_hard_limit_multibyte_boundary() {
1015        let hook = OutputHardLimitHook;
1016        let tc = make_tool_call();
1017        let td = make_tool_def();
1018        let ctx = ToolContext::new(SessionId::new());
1019        let ch = "€"; // 3 bytes
1020        let count = MAX_TOOL_RESULT_BYTES / ch.len() + 1;
1021        let big = ch.repeat(count);
1022        let mut result = ToolResult {
1023            tool_call_id: "call_test".into(),
1024            result: Some(json!(big)),
1025            images: None,
1026            error: None,
1027            connection_required: None,
1028            raw_output: None,
1029        };
1030
1031        hook.after_exec(&tc, &td, &mut result, &ctx).await;
1032
1033        let text = result.result.unwrap();
1034        let s = text.as_str().unwrap();
1035        assert!(s.len() <= MAX_TOOL_RESULT_BYTES);
1036        assert!(s.contains("[Output truncated"));
1037    }
1038
1039    #[tokio::test]
1040    async fn test_output_hard_limit_truncates_error() {
1041        let hook = OutputHardLimitHook;
1042        let tc = make_tool_call();
1043        let td = make_tool_def();
1044        let ctx = ToolContext::new(SessionId::new());
1045        let big_err = "e".repeat(MAX_TOOL_RESULT_BYTES + 500);
1046        let mut result = ToolResult {
1047            tool_call_id: "call_test".into(),
1048            result: None,
1049            images: None,
1050            error: Some(big_err),
1051            connection_required: None,
1052            raw_output: None,
1053        };
1054
1055        hook.after_exec(&tc, &td, &mut result, &ctx).await;
1056
1057        let err = result.error.unwrap();
1058        assert!(err.len() <= MAX_TOOL_RESULT_BYTES);
1059        assert!(err.ends_with(TRUNCATION_SUFFIX));
1060    }
1061
1062    #[tokio::test]
1063    async fn test_output_hard_limit_non_string_json() {
1064        let hook = OutputHardLimitHook;
1065        let tc = make_tool_call();
1066        let td = make_tool_def();
1067        let ctx = ToolContext::new(SessionId::new());
1068        // Small JSON object — should pass through
1069        let mut result = ToolResult {
1070            tool_call_id: "call_test".into(),
1071            result: Some(json!({"key": "value", "num": 42})),
1072            images: None,
1073            error: None,
1074            connection_required: None,
1075            raw_output: None,
1076        };
1077
1078        hook.after_exec(&tc, &td, &mut result, &ctx).await;
1079
1080        // Should remain as-is (small non-string JSON)
1081        assert_eq!(result.result, Some(json!({"key": "value", "num": 42})));
1082    }
1083
1084    #[tokio::test]
1085    async fn test_output_hard_limit_drops_oversized_images() {
1086        let hook = OutputHardLimitHook;
1087        let tc = make_tool_call();
1088        let td = make_tool_def();
1089        let ctx = ToolContext::new(SessionId::new());
1090
1091        let mut result = ToolResult {
1092            tool_call_id: "call_test".into(),
1093            result: Some(json!({"ok": true})),
1094            images: Some(vec![
1095                everruns_provider::ToolResultImage {
1096                    base64: "a".repeat(32),
1097                    media_type: "image/png".to_string(),
1098                },
1099                everruns_provider::ToolResultImage {
1100                    base64: "b".repeat(MAX_TOOL_RESULT_BYTES + 1),
1101                    media_type: "image/png".to_string(),
1102                },
1103            ]),
1104            error: None,
1105            connection_required: None,
1106            raw_output: None,
1107        };
1108
1109        hook.after_exec(&tc, &td, &mut result, &ctx).await;
1110
1111        let images = result.images.unwrap();
1112        assert_eq!(images.len(), 1);
1113        assert_eq!(images[0].base64.len(), 32);
1114    }
1115
1116    #[tokio::test]
1117    async fn test_output_hard_limit_enforces_cumulative_image_budget() {
1118        let hook = OutputHardLimitHook;
1119        let tc = make_tool_call();
1120        let td = make_tool_def();
1121        let ctx = ToolContext::new(SessionId::new());
1122
1123        // Each image is half the limit, so the third one tips the cumulative
1124        // budget past MAX_TOOL_RESULT_BYTES and must be dropped.
1125        let half = MAX_TOOL_RESULT_BYTES / 2;
1126        let mut result = ToolResult {
1127            tool_call_id: "call_test".into(),
1128            result: Some(json!({"ok": true})),
1129            images: Some(vec![
1130                everruns_provider::ToolResultImage {
1131                    base64: "a".repeat(half),
1132                    media_type: "image/png".to_string(),
1133                },
1134                everruns_provider::ToolResultImage {
1135                    base64: "b".repeat(half),
1136                    media_type: "image/png".to_string(),
1137                },
1138                everruns_provider::ToolResultImage {
1139                    base64: "c".repeat(half),
1140                    media_type: "image/png".to_string(),
1141                },
1142            ]),
1143            error: None,
1144            connection_required: None,
1145            raw_output: None,
1146        };
1147
1148        hook.after_exec(&tc, &td, &mut result, &ctx).await;
1149
1150        let images = result.images.unwrap();
1151        assert_eq!(
1152            images.len(),
1153            2,
1154            "third image should be dropped by cumulative budget"
1155        );
1156        assert!(images.iter().all(|i| i.base64.len() == half));
1157    }
1158
1159    #[tokio::test]
1160    async fn test_output_hard_limit_normalizes_empty_images_to_none() {
1161        let hook = OutputHardLimitHook;
1162        let tc = make_tool_call();
1163        let td = make_tool_def();
1164        let ctx = ToolContext::new(SessionId::new());
1165
1166        let mut result = ToolResult {
1167            tool_call_id: "call_test".into(),
1168            result: Some(json!({"ok": true})),
1169            images: Some(vec![everruns_provider::ToolResultImage {
1170                base64: "a".repeat(MAX_TOOL_RESULT_BYTES + 1),
1171                media_type: "image/png".to_string(),
1172            }]),
1173            error: None,
1174            connection_required: None,
1175            raw_output: None,
1176        };
1177
1178        hook.after_exec(&tc, &td, &mut result, &ctx).await;
1179
1180        assert!(
1181            result.images.is_none(),
1182            "images vec emptied by retain should normalize to None"
1183        );
1184    }
1185
1186    #[test]
1187    fn test_truncate_helper_short() {
1188        let s = "hello".to_string();
1189        assert_eq!(OutputHardLimitHook::truncate(s.clone()), s);
1190    }
1191
1192    #[test]
1193    fn test_truncate_helper_over() {
1194        let s = "a".repeat(MAX_TOOL_RESULT_BYTES + 100);
1195        let t = OutputHardLimitHook::truncate(s);
1196        assert!(t.len() <= MAX_TOOL_RESULT_BYTES);
1197        assert!(t.ends_with(TRUNCATION_SUFFIX));
1198    }
1199}