Skip to main content

agy_bridge/hooks/
types.rs

1//! Hook bridge for the Antigravity SDK.
2//!
3//! Defines Rust-side hook types that wrap callbacks for agent lifecycle
4//! hook points: pre-turn, post-turn, pre-tool-call-decide, post-tool-call,
5//! compaction, session start/end, tool errors, user interactions, and
6//! tool-input transformation.
7//!
8//! The actual Python wrapping (creating `PyO3` classes that the SDK dispatches to)
9//! requires the Python runtime and is gated behind integration tests.
10
11use std::time::SystemTime;
12
13use serde::{Deserialize, Serialize};
14
15/// Result of a hook decision (mirrors SDK `HookResult`).
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct HookResult {
18    /// Whether execution should proceed.
19    pub allow: bool,
20    /// Optional explanation or response message.
21    pub message: String,
22}
23
24impl HookResult {
25    /// Create an "allow" result with an empty message.
26    #[must_use]
27    pub const fn allow() -> Self {
28        Self {
29            allow: true,
30            message: String::new(),
31        }
32    }
33
34    /// Create an "allow" result with a message.
35    #[must_use]
36    pub fn allow_with_message(message: impl Into<String>) -> Self {
37        Self {
38            allow: true,
39            message: message.into(),
40        }
41    }
42
43    /// Create a "deny" result with a reason.
44    #[must_use]
45    pub fn deny(reason: impl Into<String>) -> Self {
46        Self {
47            allow: false,
48            message: reason.into(),
49        }
50    }
51}
52
53// ── Hook context structs ────────────────────────────────────────────────────
54
55/// Persistent session metadata passed to session-lifecycle hooks.
56///
57/// Created when a session starts and carried through to session-end hooks
58/// so hooks can correlate events, measure session duration, and identify
59/// the agent instance.
60#[non_exhaustive]
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct SessionContext {
63    /// Unique identifier for this session.
64    pub session_id: String,
65    /// Numeric agent identifier within the bridge runtime.
66    pub agent_id: u64,
67    /// Wall-clock timestamp of when the session was started.
68    ///
69    /// Defaults to [`UNIX_EPOCH`](SystemTime::UNIX_EPOCH) if the backend
70    /// does not supply this field.
71    #[serde(default = "SessionContext::default_started_at")]
72    pub started_at: SystemTime,
73}
74
75impl SessionContext {
76    /// Fallback value when `started_at` is absent from the JSON payload.
77    fn default_started_at() -> SystemTime {
78        SystemTime::UNIX_EPOCH
79    }
80}
81
82/// Context passed to [`HookPoint::OnSessionStart`] hooks.
83#[non_exhaustive]
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct OnSessionStartContext {
86    /// Session metadata for the newly started session.
87    pub session: SessionContext,
88}
89
90/// Context passed to [`HookPoint::OnSessionEnd`] hooks.
91#[non_exhaustive]
92#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
93pub struct OnSessionEndContext {
94    /// Session metadata for the ending session.
95    pub session: SessionContext,
96}
97
98/// Context passed to [`HookPoint::OnCompaction`] hooks.
99#[non_exhaustive]
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct OnCompactionContext {}
102
103/// Context passed to [`HookPoint::OnInteraction`] hooks.
104#[non_exhaustive]
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct OnInteractionContext {
107    /// The interaction message content.
108    pub message: String,
109}
110
111/// Context passed to [`HookPoint::PreTurn`] hooks.
112#[non_exhaustive]
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PreTurnContext {
115    /// The user prompt for this turn.
116    pub prompt: String,
117    /// The 1-based turn number.
118    pub turn_number: u32,
119}
120
121impl PreTurnContext {
122    /// Create a new pre-turn context.
123    #[must_use]
124    pub fn new(prompt: impl Into<String>, turn_number: u32) -> Self {
125        Self {
126            prompt: prompt.into(),
127            turn_number,
128        }
129    }
130}
131
132/// Context passed to [`HookPoint::PostTurn`] hooks.
133#[non_exhaustive]
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct PostTurnContext {
136    /// The model's response text for this turn.
137    pub response_text: String,
138    /// The 1-based turn number.
139    pub turn_number: u32,
140}
141
142/// Context passed to [`HookPoint::PreToolCallDecide`] hooks.
143#[non_exhaustive]
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PreToolCallDecideContext {
146    /// Name of the tool about to be called.
147    #[serde(alias = "name")]
148    pub tool_name: String,
149    /// Arguments the tool will receive.
150    #[serde(alias = "args", default)]
151    pub tool_args: serde_json::Value,
152}
153
154impl PreToolCallDecideContext {
155    /// Create a new pre-tool-call-decide context.
156    #[must_use]
157    pub fn new(tool_name: impl Into<String>, tool_args: serde_json::Value) -> Self {
158        Self {
159            tool_name: tool_name.into(),
160            tool_args,
161        }
162    }
163}
164
165/// Context passed to [`HookPoint::PostToolCall`] hooks.
166#[non_exhaustive]
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct PostToolCallContext {
169    /// Name of the tool that was called.
170    #[serde(alias = "name")]
171    pub tool_name: String,
172    /// Arguments the tool received.
173    #[serde(alias = "args", default)]
174    pub tool_args: serde_json::Value,
175    /// The tool's return value (serialised).
176    pub result: String,
177    /// Structured metadata from the tool response (if any).
178    #[serde(default)]
179    pub metadata: serde_json::Value,
180}
181
182/// Context passed to [`HookPoint::OnToolError`] hooks.
183///
184/// # Error contract
185///
186/// A Rust tool signals failure in one of two ways, and only one of them
187/// reaches this hook:
188///
189/// - Returning `Err(ToolError)` is a **hard** error. The model sees exactly
190///   [`ToolError::to_string()`](llm_tool::ToolError) (the human-readable
191///   `message`), and this hook fires. Any structured
192///   [`metadata`](llm_tool::ToolError::metadata) attached to the `ToolError`
193///   — which is *never* shown to the model — is surfaced here on
194///   [`metadata`](Self::metadata) so host code can branch on it.
195/// - Returning `Ok(ToolOutput)` is a **soft** result: it is delivered to the
196///   [`PostToolCall`](HookPoint::PostToolCall) hook instead, carrying its own
197///   `result` string and `metadata`.
198///
199/// This makes success and error handling symmetric: both
200/// [`PostToolCallContext`] and `OnToolErrorContext` carry structured
201/// `metadata` alongside their model-facing payload.
202#[non_exhaustive]
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct OnToolErrorContext {
205    /// Name of the tool that errored.
206    #[serde(alias = "name")]
207    pub tool_name: String,
208    /// Arguments the tool received.
209    #[serde(alias = "args", default)]
210    pub tool_args: serde_json::Value,
211    /// The error message — exactly what the model sees
212    /// ([`ToolError::to_string()`](llm_tool::ToolError)).
213    pub error: String,
214    /// Structured metadata attached to the originating
215    /// [`ToolError`](llm_tool::ToolError), if any.
216    ///
217    /// This is the error-path counterpart to
218    /// [`PostToolCallContext::metadata`]. It is populated from the
219    /// `ToolError`'s metadata map and is **never** sent to the model — it
220    /// exists purely for hooks, policies, and logging. Defaults to
221    /// [`serde_json::Value::Null`] when the error carried no metadata.
222    #[serde(default)]
223    pub metadata: serde_json::Value,
224}
225
226impl OnToolErrorContext {
227    /// Whether the error denotes a registry-lookup miss, i.e. the model asked
228    /// for a tool that isn't registered.
229    ///
230    /// This mirrors [`ToolError::is_not_found`](llm_tool::ToolError::is_not_found):
231    /// it inspects [`metadata`](Self::metadata) for the
232    /// [`ERROR_KIND_KEY`](llm_tool::ToolError::ERROR_KIND_KEY) marker set to
233    /// [`KIND_NOT_REGISTERED`](llm_tool::ToolError::KIND_NOT_REGISTERED),
234    /// letting hosts distinguish a routing miss from a genuine handler failure
235    /// without matching on message strings.
236    #[must_use]
237    pub fn is_not_found(&self) -> bool {
238        self.metadata
239            .get(llm_tool::ToolError::ERROR_KIND_KEY)
240            .and_then(serde_json::Value::as_str)
241            == Some(llm_tool::ToolError::KIND_NOT_REGISTERED)
242    }
243}
244
245/// Identifies the point in the agent lifecycle where a hook fires.
246#[non_exhaustive]
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
248pub enum HookPoint {
249    /// Before the model processes a turn (receives the user prompt).
250    PreTurn,
251    /// After the model completes a turn (receives the model response).
252    PostTurn,
253    /// Before a tool call is executed — can approve or deny.
254    PreToolCallDecide,
255    /// After a tool call completes (receives the tool result).
256    PostToolCall,
257    /// Fires when the context window is compacted (trimmed to fit limits).
258    OnCompaction,
259    /// Fires when a new agent session begins.
260    OnSessionStart,
261    /// Fires when an agent session ends.
262    OnSessionEnd,
263    /// Fires when a tool call returns an error.
264    OnToolError,
265    /// Fires on each user interaction (message received from user).
266    OnInteraction,
267}
268
269impl HookPoint {
270    /// Human-readable label for logging.
271    #[must_use]
272    pub const fn label(self) -> &'static str {
273        match self {
274            Self::PreTurn => "pre_turn",
275            Self::PostTurn => "post_turn",
276            Self::PreToolCallDecide => "pre_tool_call_decide",
277            Self::PostToolCall => "post_tool_call",
278            Self::OnCompaction => "on_compaction",
279            Self::OnSessionStart => "on_session_start",
280            Self::OnSessionEnd => "on_session_end",
281            Self::OnToolError => "on_tool_error",
282            Self::OnInteraction => "on_interaction",
283        }
284    }
285}
286
287/// A named hook registration that will be attached to an agent.
288///
289/// The `callback_id` is an opaque identifier used to look up the actual
290/// Rust callback in the hook runner. This decouples serialization from
291/// function pointers.
292///
293/// # Construction
294///
295/// Prefer [`HookEntry::new`] which validates eagerly. Direct struct
296/// construction is allowed for deserialization but skips validation —
297/// call [`HookEntry::validate`] before use if constructing manually.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct HookEntry {
300    /// Descriptive name (e.g. `"safety_gate"`).
301    pub name: String,
302    /// Which lifecycle point this hook fires at.
303    pub point: HookPoint,
304    /// Opaque callback identifier for the hook runner to resolve.
305    pub callback_id: String,
306}
307
308impl HookEntry {
309    /// Create a new hook entry, validating that `name` and `callback_id`
310    /// are non-empty.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`Error::InvalidConfig`](crate::error::Error::InvalidConfig)
315    /// if `name` or `callback_id` is empty or whitespace-only.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// # use agy_bridge::hooks::{HookEntry, HookPoint};
321    /// let entry = HookEntry::new("safety_gate", HookPoint::PreToolCallDecide, "cb_safety")
322    ///     .expect("valid entry");
323    /// assert_eq!(entry.name, "safety_gate");
324    /// ```
325    pub fn new(
326        name: impl Into<String>,
327        point: HookPoint,
328        callback_id: impl Into<String>,
329    ) -> Result<Self, crate::error::Error> {
330        let entry = Self {
331            name: name.into(),
332            point,
333            callback_id: callback_id.into(),
334        };
335        entry.validate()?;
336        Ok(entry)
337    }
338
339    /// Validate that the entry has non-empty name and `callback_id`.
340    ///
341    /// # Errors
342    ///
343    /// Returns `Err` with a description if the name or `callback_id` is empty.
344    pub fn validate(&self) -> Result<(), crate::error::Error> {
345        if self.name.trim().is_empty() {
346            return Err(crate::error::Error::InvalidConfig {
347                message: "HookEntry name must not be empty".to_owned(),
348            });
349        }
350        if self.callback_id.trim().is_empty() {
351            return Err(crate::error::Error::InvalidConfig {
352                message: format!("HookEntry '{}' has an empty callback_id", self.name),
353            });
354        }
355        Ok(())
356    }
357}
358
359/// An ordered list of hooks to attach to an agent.
360///
361/// Hooks at the same [`HookPoint`] fire in registration order.
362#[derive(Debug, Clone, Default, Serialize, Deserialize)]
363pub struct HookSet {
364    entries: Vec<HookEntry>,
365}
366
367impl HookSet {
368    /// Create an empty hook set.
369    #[must_use]
370    pub const fn new() -> Self {
371        Self {
372            entries: Vec::new(),
373        }
374    }
375
376    /// Register a hook.
377    ///
378    /// If a hook with the same name AND hook point already exists, it is
379    /// replaced and a warning is logged.
380    ///
381    /// # Errors
382    ///
383    /// Returns `Err` if the entry fails validation (empty name or `callback_id`).
384    pub fn push(&mut self, entry: HookEntry) -> Result<(), crate::error::Error> {
385        entry.validate()?;
386        if let Some(pos) = self
387            .entries
388            .iter()
389            .position(|e| e.name == entry.name && e.point == entry.point)
390        {
391            tracing::warn!(
392                hook = %entry.name,
393                point = %entry.point.label(),
394                "duplicate hook name+point in HookSet — replacing previous entry"
395            );
396            self.entries[pos] = entry;
397        } else {
398            self.entries.push(entry);
399        }
400        Ok(())
401    }
402
403    /// Iterate over hooks at a specific point, in registration order.
404    pub fn at_point(&self, point: HookPoint) -> impl Iterator<Item = &HookEntry> {
405        self.entries.iter().filter(move |e| e.point == point)
406    }
407
408    /// Iterate over all hooks.
409    pub fn iter(&self) -> impl Iterator<Item = &HookEntry> {
410        self.entries.iter()
411    }
412
413    /// Number of registered hooks.
414    #[must_use]
415    pub const fn len(&self) -> usize {
416        self.entries.len()
417    }
418
419    /// Whether the set is empty.
420    #[must_use]
421    pub const fn is_empty(&self) -> bool {
422        self.entries.is_empty()
423    }
424}
425
426impl From<HookSet> for Vec<HookEntry> {
427    fn from(set: HookSet) -> Self {
428        set.entries
429    }
430}
431
432impl From<&HookSet> for Vec<HookEntry> {
433    fn from(set: &HookSet) -> Self {
434        set.entries.clone()
435    }
436}
437
438impl IntoIterator for HookSet {
439    type Item = HookEntry;
440    type IntoIter = std::vec::IntoIter<Self::Item>;
441
442    fn into_iter(self) -> Self::IntoIter {
443        self.entries.into_iter()
444    }
445}
446
447impl FromIterator<HookEntry> for HookSet {
448    fn from_iter<T: IntoIterator<Item = HookEntry>>(iter: T) -> Self {
449        let mut set = Self::new();
450        for entry in iter {
451            let name = entry.name.clone();
452            if let Err(e) = set.push(entry) {
453                tracing::error!(
454                    error = %e,
455                    hook = %name,
456                    "Failed to push hook entry during from_iter"
457                );
458            }
459        }
460        set
461    }
462}
463
464impl From<Vec<HookEntry>> for HookSet {
465    fn from(entries: Vec<HookEntry>) -> Self {
466        Self::from_iter(entries)
467    }
468}
469
470impl<const N: usize> From<[HookEntry; N]> for HookSet {
471    fn from(entries: [HookEntry; N]) -> Self {
472        Self::from_iter(entries)
473    }
474}
475// ── Callback types ──────────────────────────────────────────────────────────
476
477/// Type alias for the transform-tool-input closure signature.
478///
479/// Accepts a pre-tool-call context and optionally returns replacement
480/// arguments.  `None` means "no change".
481type TransformToolInputFn =
482    dyn Fn(&PreToolCallDecideContext) -> Option<serde_json::Value> + Send + Sync;
483
484/// The boxed closure type behind [`HookCallback::OnToolError`].
485///
486/// Accepts a tool-error context and returns the error representation the model
487/// should see, or `None` to fall back to the harness's default formatting.
488type OnToolErrorFn = dyn Fn(&OnToolErrorContext) -> Option<String> + Send + Sync;
489
490/// A registered hook callback, keyed by hook point.
491///
492/// Each variant wraps a boxed closure that receives the strongly-typed context
493/// for that hook point. Mirroring the SDK's hook contracts:
494/// [`PreTurn`](Self::PreTurn) and [`PreToolCallDecide`](Self::PreToolCallDecide)
495/// are *deciding* hooks that return a [`HookResult`] to allow or deny the turn /
496/// tool call; [`OnToolError`](Self::OnToolError) is a *transform* hook that
497/// returns the error representation the model should see (`None` = use the
498/// harness's default formatting); all other variants are fire-and-forget
499/// observers.
500#[non_exhaustive]
501pub enum HookCallback {
502    /// Callback invoked before each agent turn.
503    ///
504    /// Returns a [`HookResult`] so it can allow or deny the turn before the
505    /// model runs (SDK `PreTurnHook`, a `DecideHook[Content]`).
506    PreTurn(Box<dyn Fn(&PreTurnContext) -> HookResult + Send + Sync>),
507    /// Callback invoked after each agent turn completes.
508    PostTurn(Box<dyn Fn(&PostTurnContext) + Send + Sync>),
509    /// Callback invoked before deciding whether to execute a tool call.
510    PreToolCallDecide(Box<dyn Fn(&PreToolCallDecideContext) -> HookResult + Send + Sync>),
511    /// Callback invoked after a tool call completes.
512    PostToolCall(Box<dyn Fn(&PostToolCallContext) + Send + Sync>),
513    /// Callback invoked when a tool call produces an error.
514    ///
515    /// Returns the error representation the model should see, or `None` to let
516    /// the harness use its default error formatting (SDK `OnToolErrorHook`, a
517    /// `TransformHook[Exception, Any]`).
518    OnToolError(Box<OnToolErrorFn>),
519    /// Callback invoked when a new agent session begins.
520    OnSessionStart(Box<dyn Fn(&OnSessionStartContext) + Send + Sync>),
521    /// Callback invoked when an agent session ends.
522    OnSessionEnd(Box<dyn Fn(&OnSessionEndContext) + Send + Sync>),
523    /// Callback invoked when conversation history is compacted.
524    OnCompaction(Box<dyn Fn(&OnCompactionContext) + Send + Sync>),
525    /// Callback invoked on each interaction event.
526    OnInteraction(Box<dyn Fn(&OnInteractionContext) -> HookResult + Send + Sync>),
527    /// Transform tool input arguments before execution.
528    ///
529    /// The closure receives the pre-tool-call context and may return
530    /// `Some(new_args)` to replace the tool arguments, or `None` to
531    /// leave them unchanged.  Multiple transform hooks are applied
532    /// sequentially — each receives the (possibly already-modified)
533    /// arguments from the previous transform.
534    TransformToolInput(Box<TransformToolInputFn>),
535}
536
537impl HookCallback {
538    /// Returns the [`HookPoint`] this callback is associated with.
539    #[must_use]
540    pub(crate) const fn hook_point(&self) -> HookPoint {
541        match self {
542            Self::PreTurn(_) => HookPoint::PreTurn,
543            Self::PostTurn(_) => HookPoint::PostTurn,
544            Self::PreToolCallDecide(_) | Self::TransformToolInput(_) => {
545                HookPoint::PreToolCallDecide
546            }
547            Self::PostToolCall(_) => HookPoint::PostToolCall,
548            Self::OnToolError(_) => HookPoint::OnToolError,
549            Self::OnSessionStart(_) => HookPoint::OnSessionStart,
550            Self::OnSessionEnd(_) => HookPoint::OnSessionEnd,
551            Self::OnCompaction(_) => HookPoint::OnCompaction,
552            Self::OnInteraction(_) => HookPoint::OnInteraction,
553        }
554    }
555}
556
557// Manual Debug impl because closures don't implement Debug.
558impl std::fmt::Debug for HookCallback {
559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560        f.write_str("HookCallback::")?;
561        match self {
562            Self::TransformToolInput(_) => f.write_str("transform_tool_input"),
563            other => f.write_str(other.hook_point().label()),
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn hook_result_allow() {
574        let r = HookResult::allow();
575        assert!(r.allow);
576        assert!(r.message.is_empty());
577    }
578
579    #[test]
580    fn hook_result_deny() {
581        let r = HookResult::deny("blocked by policy");
582        assert!(!r.allow);
583        assert_eq!(r.message, "blocked by policy");
584    }
585
586    #[test]
587    fn hook_result_allow_with_message() {
588        let r = HookResult::allow_with_message("proceeding with caution");
589        assert!(r.allow);
590        assert_eq!(r.message, "proceeding with caution");
591    }
592
593    #[test]
594    fn hook_point_labels() {
595        assert_eq!(HookPoint::PreTurn.label(), "pre_turn");
596        assert_eq!(HookPoint::PostTurn.label(), "post_turn");
597        assert_eq!(HookPoint::PreToolCallDecide.label(), "pre_tool_call_decide");
598        assert_eq!(HookPoint::PostToolCall.label(), "post_tool_call");
599        assert_eq!(HookPoint::OnCompaction.label(), "on_compaction");
600        assert_eq!(HookPoint::OnSessionStart.label(), "on_session_start");
601        assert_eq!(HookPoint::OnSessionEnd.label(), "on_session_end");
602        assert_eq!(HookPoint::OnToolError.label(), "on_tool_error");
603        assert_eq!(HookPoint::OnInteraction.label(), "on_interaction");
604    }
605
606    #[test]
607    fn hooks_fire_in_correct_order() {
608        let mut set = HookSet::new();
609        assert!(set.is_empty());
610
611        set.push(HookEntry {
612            name: "pre_turn_1".to_owned(),
613            point: HookPoint::PreTurn,
614            callback_id: "cb_pre1".to_owned(),
615        })
616        .unwrap();
617        set.push(HookEntry {
618            name: "pre_tool_decide".to_owned(),
619            point: HookPoint::PreToolCallDecide,
620            callback_id: "cb_decide".to_owned(),
621        })
622        .unwrap();
623        set.push(HookEntry {
624            name: "pre_turn_2".to_owned(),
625            point: HookPoint::PreTurn,
626            callback_id: "cb_pre2".to_owned(),
627        })
628        .unwrap();
629        set.push(HookEntry {
630            name: "post_turn_1".to_owned(),
631            point: HookPoint::PostTurn,
632            callback_id: "cb_post1".to_owned(),
633        })
634        .unwrap();
635        set.push(HookEntry {
636            name: "post_tool_1".to_owned(),
637            point: HookPoint::PostToolCall,
638            callback_id: "cb_posttool1".to_owned(),
639        })
640        .unwrap();
641
642        assert_eq!(set.len(), 5);
643
644        let pre_turn: Vec<&str> = set
645            .at_point(HookPoint::PreTurn)
646            .map(|e| e.name.as_str())
647            .collect();
648        assert_eq!(pre_turn, vec!["pre_turn_1", "pre_turn_2"]);
649
650        let decide: Vec<&str> = set
651            .at_point(HookPoint::PreToolCallDecide)
652            .map(|e| e.name.as_str())
653            .collect();
654        assert_eq!(decide, vec!["pre_tool_decide"]);
655
656        let post_turn: Vec<&str> = set
657            .at_point(HookPoint::PostTurn)
658            .map(|e| e.name.as_str())
659            .collect();
660        assert_eq!(post_turn, vec!["post_turn_1"]);
661
662        let post_tool: Vec<&str> = set
663            .at_point(HookPoint::PostToolCall)
664            .map(|e| e.name.as_str())
665            .collect();
666        assert_eq!(post_tool, vec!["post_tool_1"]);
667    }
668
669    #[test]
670    fn hook_entry_serde_roundtrip() {
671        let entry = HookEntry {
672            name: "my_hook".to_owned(),
673            point: HookPoint::PreToolCallDecide,
674            callback_id: "cb_123".to_owned(),
675        };
676        let json = serde_json::to_string(&entry).expect("serialize");
677        let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
678        assert_eq!(parsed.name, entry.name);
679        assert_eq!(parsed.point, entry.point);
680        assert_eq!(parsed.callback_id, entry.callback_id);
681    }
682
683    #[test]
684    fn hook_result_serde_roundtrip() {
685        let results = vec![
686            HookResult::allow(),
687            HookResult::deny("reason"),
688            HookResult::allow_with_message("ok"),
689        ];
690        for result in &results {
691            let json = serde_json::to_string(result).expect("serialize");
692            let parsed: HookResult = serde_json::from_str(&json).expect("deserialize");
693            assert_eq!(&parsed, result);
694        }
695    }
696
697    #[test]
698    fn hook_set_serde_roundtrip() {
699        let mut set = HookSet::new();
700        set.push(HookEntry {
701            name: "gate".to_owned(),
702            point: HookPoint::PreTurn,
703            callback_id: "cb_1".to_owned(),
704        })
705        .unwrap();
706        set.push(HookEntry {
707            name: "logger".to_owned(),
708            point: HookPoint::PostToolCall,
709            callback_id: "cb_2".to_owned(),
710        })
711        .unwrap();
712        let json = serde_json::to_string(&set).expect("serialize");
713        let parsed: HookSet = serde_json::from_str(&json).expect("deserialize");
714        assert_eq!(parsed.len(), 2);
715        let names: Vec<&str> = parsed.iter().map(|e| e.name.as_str()).collect();
716        assert_eq!(names, vec!["gate", "logger"]);
717    }
718
719    #[test]
720    fn hook_set_from_conversions() {
721        let mut set = HookSet::new();
722        set.push(HookEntry {
723            name: "gate".to_owned(),
724            point: HookPoint::PreTurn,
725            callback_id: "cb_1".to_owned(),
726        })
727        .unwrap();
728
729        let vec_from_owned: Vec<HookEntry> = Vec::from(set.clone());
730        assert_eq!(vec_from_owned.len(), 1);
731        assert_eq!(vec_from_owned[0].name, "gate");
732
733        let vec_from_ref: Vec<HookEntry> = Vec::from(&set);
734        assert_eq!(vec_from_ref.len(), 1);
735        assert_eq!(vec_from_ref[0].name, "gate");
736
737        let entry = HookEntry {
738            name: "gate".to_owned(),
739            point: HookPoint::PreTurn,
740            callback_id: "cb_1".to_owned(),
741        };
742        let set_from_arr = HookSet::from([entry.clone()]);
743        assert_eq!(set_from_arr.len(), 1);
744
745        let set_from_vec = HookSet::from(vec![entry]);
746        assert_eq!(set_from_vec.len(), 1);
747    }
748
749    #[test]
750    fn empty_hook_set_iteration_at_each_point() {
751        let set = HookSet::new();
752        for point in [
753            HookPoint::PreTurn,
754            HookPoint::PostTurn,
755            HookPoint::PreToolCallDecide,
756            HookPoint::PostToolCall,
757            HookPoint::OnCompaction,
758            HookPoint::OnSessionStart,
759            HookPoint::OnSessionEnd,
760            HookPoint::OnToolError,
761            HookPoint::OnInteraction,
762        ] {
763            assert_eq!(
764                set.at_point(point).count(),
765                0,
766                "Empty HookSet should have 0 hooks at {point:?}"
767            );
768        }
769    }
770
771    #[test]
772    fn hook_point_serde_roundtrip() {
773        let points = [
774            HookPoint::PreTurn,
775            HookPoint::PostTurn,
776            HookPoint::PreToolCallDecide,
777            HookPoint::PostToolCall,
778            HookPoint::OnCompaction,
779            HookPoint::OnSessionStart,
780            HookPoint::OnSessionEnd,
781            HookPoint::OnToolError,
782            HookPoint::OnInteraction,
783        ];
784        for point in points {
785            let json = serde_json::to_string(&point).expect("serialize");
786            let parsed: HookPoint = serde_json::from_str(&json).expect("deserialize");
787            assert_eq!(parsed, point);
788        }
789    }
790
791    #[test]
792    fn hook_set_default_is_empty() {
793        let set = HookSet::default();
794        assert!(set.is_empty());
795        assert_eq!(set.len(), 0);
796    }
797
798    #[test]
799    fn hook_set_multiple_hooks_at_same_point() {
800        let mut set = HookSet::new();
801        for i in 0..5 {
802            set.push(HookEntry {
803                name: format!("hook_{i}"),
804                point: HookPoint::PreToolCallDecide,
805                callback_id: format!("cb_{i}"),
806            })
807            .unwrap();
808        }
809        assert_eq!(set.len(), 5);
810        assert_eq!(set.at_point(HookPoint::PreToolCallDecide).count(), 5);
811        assert_eq!(set.at_point(HookPoint::PreTurn).count(), 0);
812    }
813
814    #[test]
815    fn hook_result_deny_with_string_owned() {
816        let reason = String::from("policy violation detected");
817        let r = HookResult::deny(reason.clone());
818        assert!(!r.allow);
819        assert_eq!(r.message, reason);
820    }
821
822    #[test]
823    fn hook_entry_with_new_hook_points() {
824        let new_points = [
825            (HookPoint::OnCompaction, "compaction_hook"),
826            (HookPoint::OnSessionStart, "session_start_hook"),
827            (HookPoint::OnSessionEnd, "session_end_hook"),
828            (HookPoint::OnToolError, "tool_error_hook"),
829            (HookPoint::OnInteraction, "interaction_hook"),
830        ];
831        let mut set = HookSet::new();
832        for (point, name) in &new_points {
833            set.push(HookEntry {
834                name: (*name).to_owned(),
835                point: *point,
836                callback_id: format!("cb_{name}"),
837            })
838            .unwrap();
839        }
840        assert_eq!(set.len(), 5);
841        for (point, name) in &new_points {
842            let hooks: Vec<&str> = set.at_point(*point).map(|e| e.name.as_str()).collect();
843            assert_eq!(hooks, vec![*name], "expected hook at {point:?}");
844        }
845    }
846
847    #[test]
848    fn hook_entry_serde_roundtrip_new_points() {
849        let new_points = [
850            HookPoint::OnCompaction,
851            HookPoint::OnSessionStart,
852            HookPoint::OnSessionEnd,
853            HookPoint::OnToolError,
854            HookPoint::OnInteraction,
855        ];
856        for point in new_points {
857            let entry = HookEntry {
858                name: format!("test_{}", point.label()),
859                point,
860                callback_id: format!("cb_{}", point.label()),
861            };
862            let json = serde_json::to_string(&entry).expect("serialize");
863            let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
864            assert_eq!(parsed.name, entry.name);
865            assert_eq!(parsed.point, entry.point);
866            assert_eq!(parsed.callback_id, entry.callback_id);
867        }
868    }
869
870    // ── SessionContext tests ────────────────────────────────────────────
871
872    #[test]
873    fn session_context_clone() {
874        let ctx = SessionContext {
875            session_id: "sess-1".into(),
876            agent_id: 42,
877            started_at: SystemTime::now(),
878        };
879        let cloned = ctx;
880        assert_eq!(cloned.session_id, "sess-1");
881        assert_eq!(cloned.agent_id, 42);
882    }
883
884    #[test]
885    fn session_context_debug_format() {
886        let ctx = SessionContext {
887            session_id: "sess-debug".into(),
888            agent_id: 1,
889            started_at: SystemTime::now(),
890        };
891        let dbg = format!("{ctx:?}");
892        assert!(dbg.contains("sess-debug"));
893        assert!(dbg.contains("agent_id: 1"));
894    }
895
896    #[test]
897    fn session_context_serde_roundtrip_preserves_started_at() {
898        let original = SessionContext {
899            session_id: "sess-rt".into(),
900            agent_id: 99,
901            started_at: SystemTime::now(),
902        };
903        let json = serde_json::to_string(&original).expect("serialize");
904        let parsed: SessionContext = serde_json::from_str(&json).expect("deserialize");
905
906        assert_eq!(parsed.session_id, original.session_id);
907        assert_eq!(parsed.agent_id, original.agent_id);
908        // SystemTime roundtrips through serde; Instant did not.
909        assert_eq!(parsed.started_at, original.started_at);
910    }
911
912    // ── HookEntry::new validated constructor tests ──────────────────────
913
914    #[test]
915    fn hook_entry_new_valid() {
916        let entry = HookEntry::new("safety_gate", HookPoint::PreToolCallDecide, "cb_safety")
917            .expect("valid entry");
918        assert_eq!(entry.name, "safety_gate");
919        assert_eq!(entry.point, HookPoint::PreToolCallDecide);
920        assert_eq!(entry.callback_id, "cb_safety");
921    }
922
923    #[test]
924    fn hook_entry_new_rejects_empty_name() {
925        let result = HookEntry::new("", HookPoint::PreTurn, "cb_1");
926        assert!(result.is_err(), "should reject empty name");
927    }
928
929    #[test]
930    fn hook_entry_new_rejects_whitespace_name() {
931        let result = HookEntry::new("   ", HookPoint::PreTurn, "cb_1");
932        assert!(result.is_err(), "should reject whitespace-only name");
933    }
934
935    #[test]
936    fn hook_entry_new_rejects_empty_callback_id() {
937        let result = HookEntry::new("my_hook", HookPoint::PreTurn, "");
938        assert!(result.is_err(), "should reject empty callback_id");
939    }
940
941    #[test]
942    fn hook_entry_new_rejects_whitespace_callback_id() {
943        let result = HookEntry::new("my_hook", HookPoint::PostTurn, "  ");
944        assert!(result.is_err(), "should reject whitespace-only callback_id");
945    }
946
947    #[test]
948    fn pre_tool_call_decide_context_serde_aliases() {
949        let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"}}"#;
950        let parsed_std: PreToolCallDecideContext = serde_json::from_str(json_std).unwrap();
951        assert_eq!(parsed_std.tool_name, "my_tool");
952        assert_eq!(parsed_std.tool_args["foo"], "bar");
953
954        let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"}}"#;
955        let parsed_alias: PreToolCallDecideContext = serde_json::from_str(json_alias).unwrap();
956        assert_eq!(parsed_alias.tool_name, "my_tool");
957        assert_eq!(parsed_alias.tool_args["foo"], "bar");
958    }
959
960    #[test]
961    fn pre_tool_call_decide_context_serde_default() {
962        let json_no_args = r#"{"name":"my_tool"}"#;
963        let parsed_no_args: PreToolCallDecideContext = serde_json::from_str(json_no_args).unwrap();
964        assert_eq!(parsed_no_args.tool_name, "my_tool");
965        assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
966    }
967
968    #[test]
969    fn post_tool_call_context_serde_aliases_and_default() {
970        let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"result":"success"}"#;
971        let parsed_std: PostToolCallContext = serde_json::from_str(json_std).unwrap();
972        assert_eq!(parsed_std.tool_name, "my_tool");
973        assert_eq!(parsed_std.tool_args["foo"], "bar");
974        assert_eq!(parsed_std.result, "success");
975
976        let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"result":"success"}"#;
977        let parsed_alias: PostToolCallContext = serde_json::from_str(json_alias).unwrap();
978        assert_eq!(parsed_alias.tool_name, "my_tool");
979        assert_eq!(parsed_alias.tool_args["foo"], "bar");
980        assert_eq!(parsed_alias.result, "success");
981
982        let json_no_args = r#"{"name":"my_tool","result":"success"}"#;
983        let parsed_no_args: PostToolCallContext = serde_json::from_str(json_no_args).unwrap();
984        assert_eq!(parsed_no_args.tool_name, "my_tool");
985        assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
986        assert_eq!(parsed_no_args.result, "success");
987    }
988
989    #[test]
990    fn on_tool_error_context_serde_aliases_and_default() {
991        let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"error":"failed"}"#;
992        let parsed_std: OnToolErrorContext = serde_json::from_str(json_std).unwrap();
993        assert_eq!(parsed_std.tool_name, "my_tool");
994        assert_eq!(parsed_std.tool_args["foo"], "bar");
995        assert_eq!(parsed_std.error, "failed");
996
997        let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"error":"failed"}"#;
998        let parsed_alias: OnToolErrorContext = serde_json::from_str(json_alias).unwrap();
999        assert_eq!(parsed_alias.tool_name, "my_tool");
1000        assert_eq!(parsed_alias.tool_args["foo"], "bar");
1001        assert_eq!(parsed_alias.error, "failed");
1002
1003        let json_no_args = r#"{"name":"my_tool","error":"failed"}"#;
1004        let parsed_no_args: OnToolErrorContext = serde_json::from_str(json_no_args).unwrap();
1005        assert_eq!(parsed_no_args.tool_name, "my_tool");
1006        assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
1007        assert_eq!(parsed_no_args.error, "failed");
1008
1009        let json_no_name = r#"{"error":"failed"}"#;
1010        let parsed_no_name: Result<OnToolErrorContext, _> = serde_json::from_str(json_no_name);
1011        assert!(parsed_no_name.is_err());
1012    }
1013
1014    #[test]
1015    fn on_tool_error_context_metadata_defaults_to_null() {
1016        let json = r#"{"tool_name":"my_tool","error":"failed"}"#;
1017        let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1018        assert_eq!(parsed.metadata, serde_json::Value::Null);
1019        assert!(!parsed.is_not_found());
1020    }
1021
1022    #[test]
1023    fn on_tool_error_context_metadata_deserialized() {
1024        let json = r#"{"tool_name":"my_tool","error":"failed","metadata":{"status_code":503}}"#;
1025        let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1026        assert_eq!(parsed.metadata["status_code"], 503);
1027    }
1028
1029    #[test]
1030    fn on_tool_error_context_is_not_found_detects_registry_miss() {
1031        // Metadata mirrors what `ToolError::not_found` attaches.
1032        let error = llm_tool::ToolError::not_found(llm_tool::RegistryItem::Tool, "add_nummbers");
1033        let ctx = OnToolErrorContext {
1034            tool_name: "add_nummbers".into(),
1035            tool_args: serde_json::Value::Null,
1036            error: error.to_string(),
1037            metadata: serde_json::to_value(error.metadata()).unwrap(),
1038        };
1039        assert!(ctx.is_not_found());
1040    }
1041
1042    #[test]
1043    fn on_tool_error_context_is_not_found_false_for_generic_error() {
1044        let ctx = OnToolErrorContext {
1045            tool_name: "t".into(),
1046            tool_args: serde_json::Value::Null,
1047            error: "handler blew up".into(),
1048            metadata: serde_json::json!({"some": "value"}),
1049        };
1050        assert!(!ctx.is_not_found());
1051    }
1052}