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