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/// A registered hook callback, keyed by hook point.
485///
486/// Each variant wraps a boxed closure that receives the strongly-typed context
487/// for that hook point.  [`PreToolCallDecide`](Self::PreToolCallDecide) returns
488/// a [`HookResult`] so it can approve or deny tool execution; all other
489/// variants are fire-and-forget observers.
490#[non_exhaustive]
491pub enum HookCallback {
492    /// Callback invoked before each agent turn.
493    PreTurn(Box<dyn Fn(&PreTurnContext) + Send + Sync>),
494    /// Callback invoked after each agent turn completes.
495    PostTurn(Box<dyn Fn(&PostTurnContext) + Send + Sync>),
496    /// Callback invoked before deciding whether to execute a tool call.
497    PreToolCallDecide(Box<dyn Fn(&PreToolCallDecideContext) -> HookResult + Send + Sync>),
498    /// Callback invoked after a tool call completes.
499    PostToolCall(Box<dyn Fn(&PostToolCallContext) + Send + Sync>),
500    /// Callback invoked when a tool call produces an error.
501    OnToolError(Box<dyn Fn(&OnToolErrorContext) + Send + Sync>),
502    /// Callback invoked when a new agent session begins.
503    OnSessionStart(Box<dyn Fn(&OnSessionStartContext) + Send + Sync>),
504    /// Callback invoked when an agent session ends.
505    OnSessionEnd(Box<dyn Fn(&OnSessionEndContext) + Send + Sync>),
506    /// Callback invoked when conversation history is compacted.
507    OnCompaction(Box<dyn Fn(&OnCompactionContext) + Send + Sync>),
508    /// Callback invoked on each interaction event.
509    OnInteraction(Box<dyn Fn(&OnInteractionContext) -> HookResult + Send + Sync>),
510    /// Transform tool input arguments before execution.
511    ///
512    /// The closure receives the pre-tool-call context and may return
513    /// `Some(new_args)` to replace the tool arguments, or `None` to
514    /// leave them unchanged.  Multiple transform hooks are applied
515    /// sequentially — each receives the (possibly already-modified)
516    /// arguments from the previous transform.
517    TransformToolInput(Box<TransformToolInputFn>),
518}
519
520impl HookCallback {
521    /// Returns the [`HookPoint`] this callback is associated with.
522    #[must_use]
523    pub(crate) const fn hook_point(&self) -> HookPoint {
524        match self {
525            Self::PreTurn(_) => HookPoint::PreTurn,
526            Self::PostTurn(_) => HookPoint::PostTurn,
527            Self::PreToolCallDecide(_) | Self::TransformToolInput(_) => {
528                HookPoint::PreToolCallDecide
529            }
530            Self::PostToolCall(_) => HookPoint::PostToolCall,
531            Self::OnToolError(_) => HookPoint::OnToolError,
532            Self::OnSessionStart(_) => HookPoint::OnSessionStart,
533            Self::OnSessionEnd(_) => HookPoint::OnSessionEnd,
534            Self::OnCompaction(_) => HookPoint::OnCompaction,
535            Self::OnInteraction(_) => HookPoint::OnInteraction,
536        }
537    }
538}
539
540// Manual Debug impl because closures don't implement Debug.
541impl std::fmt::Debug for HookCallback {
542    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
543        f.write_str("HookCallback::")?;
544        match self {
545            Self::TransformToolInput(_) => f.write_str("transform_tool_input"),
546            other => f.write_str(other.hook_point().label()),
547        }
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn hook_result_allow() {
557        let r = HookResult::allow();
558        assert!(r.allow);
559        assert!(r.message.is_empty());
560    }
561
562    #[test]
563    fn hook_result_deny() {
564        let r = HookResult::deny("blocked by policy");
565        assert!(!r.allow);
566        assert_eq!(r.message, "blocked by policy");
567    }
568
569    #[test]
570    fn hook_result_allow_with_message() {
571        let r = HookResult::allow_with_message("proceeding with caution");
572        assert!(r.allow);
573        assert_eq!(r.message, "proceeding with caution");
574    }
575
576    #[test]
577    fn hook_point_labels() {
578        assert_eq!(HookPoint::PreTurn.label(), "pre_turn");
579        assert_eq!(HookPoint::PostTurn.label(), "post_turn");
580        assert_eq!(HookPoint::PreToolCallDecide.label(), "pre_tool_call_decide");
581        assert_eq!(HookPoint::PostToolCall.label(), "post_tool_call");
582        assert_eq!(HookPoint::OnCompaction.label(), "on_compaction");
583        assert_eq!(HookPoint::OnSessionStart.label(), "on_session_start");
584        assert_eq!(HookPoint::OnSessionEnd.label(), "on_session_end");
585        assert_eq!(HookPoint::OnToolError.label(), "on_tool_error");
586        assert_eq!(HookPoint::OnInteraction.label(), "on_interaction");
587    }
588
589    #[test]
590    fn hooks_fire_in_correct_order() {
591        let mut set = HookSet::new();
592        assert!(set.is_empty());
593
594        set.push(HookEntry {
595            name: "pre_turn_1".to_owned(),
596            point: HookPoint::PreTurn,
597            callback_id: "cb_pre1".to_owned(),
598        })
599        .unwrap();
600        set.push(HookEntry {
601            name: "pre_tool_decide".to_owned(),
602            point: HookPoint::PreToolCallDecide,
603            callback_id: "cb_decide".to_owned(),
604        })
605        .unwrap();
606        set.push(HookEntry {
607            name: "pre_turn_2".to_owned(),
608            point: HookPoint::PreTurn,
609            callback_id: "cb_pre2".to_owned(),
610        })
611        .unwrap();
612        set.push(HookEntry {
613            name: "post_turn_1".to_owned(),
614            point: HookPoint::PostTurn,
615            callback_id: "cb_post1".to_owned(),
616        })
617        .unwrap();
618        set.push(HookEntry {
619            name: "post_tool_1".to_owned(),
620            point: HookPoint::PostToolCall,
621            callback_id: "cb_posttool1".to_owned(),
622        })
623        .unwrap();
624
625        assert_eq!(set.len(), 5);
626
627        let pre_turn: Vec<&str> = set
628            .at_point(HookPoint::PreTurn)
629            .map(|e| e.name.as_str())
630            .collect();
631        assert_eq!(pre_turn, vec!["pre_turn_1", "pre_turn_2"]);
632
633        let decide: Vec<&str> = set
634            .at_point(HookPoint::PreToolCallDecide)
635            .map(|e| e.name.as_str())
636            .collect();
637        assert_eq!(decide, vec!["pre_tool_decide"]);
638
639        let post_turn: Vec<&str> = set
640            .at_point(HookPoint::PostTurn)
641            .map(|e| e.name.as_str())
642            .collect();
643        assert_eq!(post_turn, vec!["post_turn_1"]);
644
645        let post_tool: Vec<&str> = set
646            .at_point(HookPoint::PostToolCall)
647            .map(|e| e.name.as_str())
648            .collect();
649        assert_eq!(post_tool, vec!["post_tool_1"]);
650    }
651
652    #[test]
653    fn hook_entry_serde_roundtrip() {
654        let entry = HookEntry {
655            name: "my_hook".to_owned(),
656            point: HookPoint::PreToolCallDecide,
657            callback_id: "cb_123".to_owned(),
658        };
659        let json = serde_json::to_string(&entry).expect("serialize");
660        let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
661        assert_eq!(parsed.name, entry.name);
662        assert_eq!(parsed.point, entry.point);
663        assert_eq!(parsed.callback_id, entry.callback_id);
664    }
665
666    #[test]
667    fn hook_result_serde_roundtrip() {
668        let results = vec![
669            HookResult::allow(),
670            HookResult::deny("reason"),
671            HookResult::allow_with_message("ok"),
672        ];
673        for result in &results {
674            let json = serde_json::to_string(result).expect("serialize");
675            let parsed: HookResult = serde_json::from_str(&json).expect("deserialize");
676            assert_eq!(&parsed, result);
677        }
678    }
679
680    #[test]
681    fn hook_set_serde_roundtrip() {
682        let mut set = HookSet::new();
683        set.push(HookEntry {
684            name: "gate".to_owned(),
685            point: HookPoint::PreTurn,
686            callback_id: "cb_1".to_owned(),
687        })
688        .unwrap();
689        set.push(HookEntry {
690            name: "logger".to_owned(),
691            point: HookPoint::PostToolCall,
692            callback_id: "cb_2".to_owned(),
693        })
694        .unwrap();
695        let json = serde_json::to_string(&set).expect("serialize");
696        let parsed: HookSet = serde_json::from_str(&json).expect("deserialize");
697        assert_eq!(parsed.len(), 2);
698        let names: Vec<&str> = parsed.iter().map(|e| e.name.as_str()).collect();
699        assert_eq!(names, vec!["gate", "logger"]);
700    }
701
702    #[test]
703    fn hook_set_from_conversions() {
704        let mut set = HookSet::new();
705        set.push(HookEntry {
706            name: "gate".to_owned(),
707            point: HookPoint::PreTurn,
708            callback_id: "cb_1".to_owned(),
709        })
710        .unwrap();
711
712        let vec_from_owned: Vec<HookEntry> = Vec::from(set.clone());
713        assert_eq!(vec_from_owned.len(), 1);
714        assert_eq!(vec_from_owned[0].name, "gate");
715
716        let vec_from_ref: Vec<HookEntry> = Vec::from(&set);
717        assert_eq!(vec_from_ref.len(), 1);
718        assert_eq!(vec_from_ref[0].name, "gate");
719
720        let entry = HookEntry {
721            name: "gate".to_owned(),
722            point: HookPoint::PreTurn,
723            callback_id: "cb_1".to_owned(),
724        };
725        let set_from_arr = HookSet::from([entry.clone()]);
726        assert_eq!(set_from_arr.len(), 1);
727
728        let set_from_vec = HookSet::from(vec![entry]);
729        assert_eq!(set_from_vec.len(), 1);
730    }
731
732    #[test]
733    fn empty_hook_set_iteration_at_each_point() {
734        let set = HookSet::new();
735        for point in [
736            HookPoint::PreTurn,
737            HookPoint::PostTurn,
738            HookPoint::PreToolCallDecide,
739            HookPoint::PostToolCall,
740            HookPoint::OnCompaction,
741            HookPoint::OnSessionStart,
742            HookPoint::OnSessionEnd,
743            HookPoint::OnToolError,
744            HookPoint::OnInteraction,
745        ] {
746            assert_eq!(
747                set.at_point(point).count(),
748                0,
749                "Empty HookSet should have 0 hooks at {point:?}"
750            );
751        }
752    }
753
754    #[test]
755    fn hook_point_serde_roundtrip() {
756        let points = [
757            HookPoint::PreTurn,
758            HookPoint::PostTurn,
759            HookPoint::PreToolCallDecide,
760            HookPoint::PostToolCall,
761            HookPoint::OnCompaction,
762            HookPoint::OnSessionStart,
763            HookPoint::OnSessionEnd,
764            HookPoint::OnToolError,
765            HookPoint::OnInteraction,
766        ];
767        for point in points {
768            let json = serde_json::to_string(&point).expect("serialize");
769            let parsed: HookPoint = serde_json::from_str(&json).expect("deserialize");
770            assert_eq!(parsed, point);
771        }
772    }
773
774    #[test]
775    fn hook_set_default_is_empty() {
776        let set = HookSet::default();
777        assert!(set.is_empty());
778        assert_eq!(set.len(), 0);
779    }
780
781    #[test]
782    fn hook_set_multiple_hooks_at_same_point() {
783        let mut set = HookSet::new();
784        for i in 0..5 {
785            set.push(HookEntry {
786                name: format!("hook_{i}"),
787                point: HookPoint::PreToolCallDecide,
788                callback_id: format!("cb_{i}"),
789            })
790            .unwrap();
791        }
792        assert_eq!(set.len(), 5);
793        assert_eq!(set.at_point(HookPoint::PreToolCallDecide).count(), 5);
794        assert_eq!(set.at_point(HookPoint::PreTurn).count(), 0);
795    }
796
797    #[test]
798    fn hook_result_deny_with_string_owned() {
799        let reason = String::from("policy violation detected");
800        let r = HookResult::deny(reason.clone());
801        assert!(!r.allow);
802        assert_eq!(r.message, reason);
803    }
804
805    #[test]
806    fn hook_entry_with_new_hook_points() {
807        let new_points = [
808            (HookPoint::OnCompaction, "compaction_hook"),
809            (HookPoint::OnSessionStart, "session_start_hook"),
810            (HookPoint::OnSessionEnd, "session_end_hook"),
811            (HookPoint::OnToolError, "tool_error_hook"),
812            (HookPoint::OnInteraction, "interaction_hook"),
813        ];
814        let mut set = HookSet::new();
815        for (point, name) in &new_points {
816            set.push(HookEntry {
817                name: (*name).to_owned(),
818                point: *point,
819                callback_id: format!("cb_{name}"),
820            })
821            .unwrap();
822        }
823        assert_eq!(set.len(), 5);
824        for (point, name) in &new_points {
825            let hooks: Vec<&str> = set.at_point(*point).map(|e| e.name.as_str()).collect();
826            assert_eq!(hooks, vec![*name], "expected hook at {point:?}");
827        }
828    }
829
830    #[test]
831    fn hook_entry_serde_roundtrip_new_points() {
832        let new_points = [
833            HookPoint::OnCompaction,
834            HookPoint::OnSessionStart,
835            HookPoint::OnSessionEnd,
836            HookPoint::OnToolError,
837            HookPoint::OnInteraction,
838        ];
839        for point in new_points {
840            let entry = HookEntry {
841                name: format!("test_{}", point.label()),
842                point,
843                callback_id: format!("cb_{}", point.label()),
844            };
845            let json = serde_json::to_string(&entry).expect("serialize");
846            let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
847            assert_eq!(parsed.name, entry.name);
848            assert_eq!(parsed.point, entry.point);
849            assert_eq!(parsed.callback_id, entry.callback_id);
850        }
851    }
852
853    // ── SessionContext tests ────────────────────────────────────────────
854
855    #[test]
856    fn session_context_clone() {
857        let ctx = SessionContext {
858            session_id: "sess-1".into(),
859            agent_id: 42,
860            started_at: SystemTime::now(),
861        };
862        let cloned = ctx;
863        assert_eq!(cloned.session_id, "sess-1");
864        assert_eq!(cloned.agent_id, 42);
865    }
866
867    #[test]
868    fn session_context_debug_format() {
869        let ctx = SessionContext {
870            session_id: "sess-debug".into(),
871            agent_id: 1,
872            started_at: SystemTime::now(),
873        };
874        let dbg = format!("{ctx:?}");
875        assert!(dbg.contains("sess-debug"));
876        assert!(dbg.contains("agent_id: 1"));
877    }
878
879    #[test]
880    fn session_context_serde_roundtrip_preserves_started_at() {
881        let original = SessionContext {
882            session_id: "sess-rt".into(),
883            agent_id: 99,
884            started_at: SystemTime::now(),
885        };
886        let json = serde_json::to_string(&original).expect("serialize");
887        let parsed: SessionContext = serde_json::from_str(&json).expect("deserialize");
888
889        assert_eq!(parsed.session_id, original.session_id);
890        assert_eq!(parsed.agent_id, original.agent_id);
891        // SystemTime roundtrips through serde; Instant did not.
892        assert_eq!(parsed.started_at, original.started_at);
893    }
894
895    // ── HookEntry::new validated constructor tests ──────────────────────
896
897    #[test]
898    fn hook_entry_new_valid() {
899        let entry = HookEntry::new("safety_gate", HookPoint::PreToolCallDecide, "cb_safety")
900            .expect("valid entry");
901        assert_eq!(entry.name, "safety_gate");
902        assert_eq!(entry.point, HookPoint::PreToolCallDecide);
903        assert_eq!(entry.callback_id, "cb_safety");
904    }
905
906    #[test]
907    fn hook_entry_new_rejects_empty_name() {
908        let result = HookEntry::new("", HookPoint::PreTurn, "cb_1");
909        assert!(result.is_err(), "should reject empty name");
910    }
911
912    #[test]
913    fn hook_entry_new_rejects_whitespace_name() {
914        let result = HookEntry::new("   ", HookPoint::PreTurn, "cb_1");
915        assert!(result.is_err(), "should reject whitespace-only name");
916    }
917
918    #[test]
919    fn hook_entry_new_rejects_empty_callback_id() {
920        let result = HookEntry::new("my_hook", HookPoint::PreTurn, "");
921        assert!(result.is_err(), "should reject empty callback_id");
922    }
923
924    #[test]
925    fn hook_entry_new_rejects_whitespace_callback_id() {
926        let result = HookEntry::new("my_hook", HookPoint::PostTurn, "  ");
927        assert!(result.is_err(), "should reject whitespace-only callback_id");
928    }
929
930    #[test]
931    fn pre_tool_call_decide_context_serde_aliases() {
932        let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"}}"#;
933        let parsed_std: PreToolCallDecideContext = serde_json::from_str(json_std).unwrap();
934        assert_eq!(parsed_std.tool_name, "my_tool");
935        assert_eq!(parsed_std.tool_args["foo"], "bar");
936
937        let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"}}"#;
938        let parsed_alias: PreToolCallDecideContext = serde_json::from_str(json_alias).unwrap();
939        assert_eq!(parsed_alias.tool_name, "my_tool");
940        assert_eq!(parsed_alias.tool_args["foo"], "bar");
941    }
942
943    #[test]
944    fn pre_tool_call_decide_context_serde_default() {
945        let json_no_args = r#"{"name":"my_tool"}"#;
946        let parsed_no_args: PreToolCallDecideContext = serde_json::from_str(json_no_args).unwrap();
947        assert_eq!(parsed_no_args.tool_name, "my_tool");
948        assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
949    }
950
951    #[test]
952    fn post_tool_call_context_serde_aliases_and_default() {
953        let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"result":"success"}"#;
954        let parsed_std: PostToolCallContext = serde_json::from_str(json_std).unwrap();
955        assert_eq!(parsed_std.tool_name, "my_tool");
956        assert_eq!(parsed_std.tool_args["foo"], "bar");
957        assert_eq!(parsed_std.result, "success");
958
959        let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"result":"success"}"#;
960        let parsed_alias: PostToolCallContext = serde_json::from_str(json_alias).unwrap();
961        assert_eq!(parsed_alias.tool_name, "my_tool");
962        assert_eq!(parsed_alias.tool_args["foo"], "bar");
963        assert_eq!(parsed_alias.result, "success");
964
965        let json_no_args = r#"{"name":"my_tool","result":"success"}"#;
966        let parsed_no_args: PostToolCallContext = serde_json::from_str(json_no_args).unwrap();
967        assert_eq!(parsed_no_args.tool_name, "my_tool");
968        assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
969        assert_eq!(parsed_no_args.result, "success");
970    }
971
972    #[test]
973    fn on_tool_error_context_serde_aliases_and_default() {
974        let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"error":"failed"}"#;
975        let parsed_std: OnToolErrorContext = serde_json::from_str(json_std).unwrap();
976        assert_eq!(parsed_std.tool_name, "my_tool");
977        assert_eq!(parsed_std.tool_args["foo"], "bar");
978        assert_eq!(parsed_std.error, "failed");
979
980        let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"error":"failed"}"#;
981        let parsed_alias: OnToolErrorContext = serde_json::from_str(json_alias).unwrap();
982        assert_eq!(parsed_alias.tool_name, "my_tool");
983        assert_eq!(parsed_alias.tool_args["foo"], "bar");
984        assert_eq!(parsed_alias.error, "failed");
985
986        let json_no_args = r#"{"name":"my_tool","error":"failed"}"#;
987        let parsed_no_args: OnToolErrorContext = serde_json::from_str(json_no_args).unwrap();
988        assert_eq!(parsed_no_args.tool_name, "my_tool");
989        assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
990        assert_eq!(parsed_no_args.error, "failed");
991
992        let json_no_name = r#"{"error":"failed"}"#;
993        let parsed_no_name: Result<OnToolErrorContext, _> = serde_json::from_str(json_no_name);
994        assert!(parsed_no_name.is_err());
995    }
996
997    #[test]
998    fn on_tool_error_context_metadata_defaults_to_null() {
999        let json = r#"{"tool_name":"my_tool","error":"failed"}"#;
1000        let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1001        assert_eq!(parsed.metadata, serde_json::Value::Null);
1002        assert!(!parsed.is_not_found());
1003    }
1004
1005    #[test]
1006    fn on_tool_error_context_metadata_deserialized() {
1007        let json = r#"{"tool_name":"my_tool","error":"failed","metadata":{"status_code":503}}"#;
1008        let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1009        assert_eq!(parsed.metadata["status_code"], 503);
1010    }
1011
1012    #[test]
1013    fn on_tool_error_context_is_not_found_detects_registry_miss() {
1014        // Metadata mirrors what `ToolError::not_found` attaches.
1015        let error = llm_tool::ToolError::not_found(llm_tool::RegistryItem::Tool, "add_nummbers");
1016        let ctx = OnToolErrorContext {
1017            tool_name: "add_nummbers".into(),
1018            tool_args: serde_json::Value::Null,
1019            error: error.to_string(),
1020            metadata: serde_json::to_value(error.metadata()).unwrap(),
1021        };
1022        assert!(ctx.is_not_found());
1023    }
1024
1025    #[test]
1026    fn on_tool_error_context_is_not_found_false_for_generic_error() {
1027        let ctx = OnToolErrorContext {
1028            tool_name: "t".into(),
1029            tool_args: serde_json::Value::Null,
1030            error: "handler blew up".into(),
1031            metadata: serde_json::json!({"some": "value"}),
1032        };
1033        assert!(!ctx.is_not_found());
1034    }
1035}