Skip to main content

pi/
extension_events.rs

1//! Typed extension event definitions + dispatch helper.
2//!
3//! This module defines the JSON-serializable event payloads that can be sent to
4//! JavaScript extensions via the `dispatch_event` hook system.
5
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::sync::Arc;
10
11use crate::error::{Error, Result};
12use crate::extensions::{EXTENSION_EVENT_TIMEOUT_MS, ExtensionRuntimeHandle};
13use crate::model::{AssistantMessage, ContentBlock, ImageContent, Message, ToolResultMessage};
14
15/// Events that can be dispatched to extension handlers.
16///
17/// The serialized representation is tagged with `type` in `snake_case`, matching
18/// the string event name used by JS hooks (e.g. `"tool_call"`).
19#[derive(Debug, Clone, Serialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum ExtensionEvent {
22    /// Agent startup (once per session).
23    #[serde(rename_all = "camelCase")]
24    Startup {
25        version: String,
26        session_file: Option<String>,
27    },
28
29    /// Before first API call in a run.
30    #[serde(rename_all = "camelCase")]
31    AgentStart { session_id: String },
32
33    /// After agent loop ends.
34    #[serde(rename_all = "camelCase")]
35    AgentEnd {
36        session_id: String,
37        messages: Vec<Message>,
38        error: Option<String>,
39    },
40
41    /// Before provider.stream() call.
42    #[serde(rename_all = "camelCase")]
43    TurnStart {
44        session_id: String,
45        turn_index: usize,
46    },
47
48    /// After response processed.
49    #[serde(rename_all = "camelCase")]
50    TurnEnd {
51        session_id: String,
52        turn_index: usize,
53        message: AssistantMessage,
54        tool_results: Vec<ToolResultMessage>,
55    },
56
57    /// Before tool execution (can block).
58    #[serde(rename_all = "camelCase")]
59    ToolCall {
60        tool_name: String,
61        tool_call_id: String,
62        input: Value,
63    },
64
65    /// After tool execution (can modify result).
66    #[serde(rename_all = "camelCase")]
67    ToolResult {
68        tool_name: String,
69        tool_call_id: String,
70        input: Value,
71        content: Vec<ContentBlock>,
72        details: Option<Value>,
73        is_error: bool,
74    },
75
76    /// Before session switch (can cancel).
77    #[serde(rename_all = "camelCase")]
78    SessionBeforeSwitch {
79        current_session: Option<String>,
80        target_session: String,
81    },
82
83    /// Before session fork (can cancel).
84    #[serde(rename_all = "camelCase")]
85    SessionBeforeFork {
86        current_session: Option<String>,
87        fork_entry_id: String,
88    },
89
90    /// Before processing user input (can transform).
91    #[serde(rename_all = "camelCase")]
92    Input {
93        #[serde(rename = "text")]
94        content: String,
95        #[serde(rename = "images")]
96        attachments: Vec<ImageContent>,
97    },
98}
99
100impl ExtensionEvent {
101    /// Get the event name for dispatch.
102    #[must_use]
103    pub const fn event_name(&self) -> &'static str {
104        match self {
105            Self::Startup { .. } => "startup",
106            Self::AgentStart { .. } => "agent_start",
107            Self::AgentEnd { .. } => "agent_end",
108            Self::TurnStart { .. } => "turn_start",
109            Self::TurnEnd { .. } => "turn_end",
110            Self::ToolCall { .. } => "tool_call",
111            Self::ToolResult { .. } => "tool_result",
112            Self::SessionBeforeSwitch { .. } => "session_before_switch",
113            Self::SessionBeforeFork { .. } => "session_before_fork",
114            Self::Input { .. } => "input",
115        }
116    }
117}
118
119/// Result from a tool_call event handler.
120#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "camelCase")]
122pub struct ToolCallEventResult {
123    /// If true, block tool execution.
124    #[serde(default)]
125    pub block: bool,
126
127    /// Reason for blocking (shown to user).
128    pub reason: Option<String>,
129}
130
131/// Result from a tool_result event handler.
132#[derive(Debug, Clone, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct ToolResultEventResult {
135    /// Modified content (if None, use original).
136    pub content: Option<Vec<ContentBlock>>,
137
138    /// Modified details (if None, use original).
139    pub details: Option<Value>,
140}
141
142/// Result from an input event handler.
143#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "camelCase")]
145pub struct InputEventResult {
146    /// Transformed content (if None, use original).
147    pub content: Option<String>,
148
149    /// If true, block processing.
150    #[serde(default)]
151    pub block: bool,
152
153    /// Reason for blocking.
154    pub reason: Option<String>,
155}
156
157#[derive(Debug, Clone)]
158pub enum InputEventOutcome {
159    Continue {
160        text: String,
161        images: Vec<ImageContent>,
162    },
163    Block {
164        reason: Option<String>,
165    },
166}
167
168#[must_use]
169pub fn apply_input_event_response(
170    response: Option<Value>,
171    original_text: String,
172    original_images: Vec<ImageContent>,
173) -> InputEventOutcome {
174    let Some(response) = response else {
175        return InputEventOutcome::Continue {
176            text: original_text,
177            images: original_images,
178        };
179    };
180
181    if response.is_null() {
182        return InputEventOutcome::Continue {
183            text: original_text,
184            images: original_images,
185        };
186    }
187
188    if let Some(obj) = response.as_object() {
189        let reason = obj
190            .get("reason")
191            .or_else(|| obj.get("message"))
192            .and_then(Value::as_str)
193            .map(str::to_string);
194
195        if let Some(action) = obj
196            .get("action")
197            .and_then(Value::as_str)
198            .map(str::to_ascii_lowercase)
199        {
200            match action.as_str() {
201                "handled" | "block" | "blocked" => {
202                    return InputEventOutcome::Block { reason };
203                }
204                "transform" => {
205                    let text = obj
206                        .get("text")
207                        .or_else(|| obj.get("content"))
208                        .and_then(Value::as_str)
209                        .map(str::to_string)
210                        .unwrap_or(original_text);
211                    let images = parse_input_event_images(obj, original_images);
212                    return InputEventOutcome::Continue { text, images };
213                }
214                "continue" => {
215                    return InputEventOutcome::Continue {
216                        text: original_text,
217                        images: original_images,
218                    };
219                }
220                _ => {}
221            }
222        }
223
224        if obj.get("block").and_then(Value::as_bool) == Some(true) {
225            return InputEventOutcome::Block { reason };
226        }
227
228        let text_override = obj
229            .get("text")
230            .or_else(|| obj.get("content"))
231            .and_then(Value::as_str)
232            .map(str::to_string);
233        let images_override = parse_input_event_images_opt(obj);
234
235        if text_override.is_some() || images_override.is_some() {
236            return InputEventOutcome::Continue {
237                text: text_override.unwrap_or(original_text),
238                images: images_override.unwrap_or(original_images),
239            };
240        }
241    }
242
243    if let Some(text) = response.as_str() {
244        return InputEventOutcome::Continue {
245            text: text.to_string(),
246            images: original_images,
247        };
248    }
249
250    InputEventOutcome::Continue {
251        text: original_text,
252        images: original_images,
253    }
254}
255
256fn parse_input_event_images_opt(obj: &serde_json::Map<String, Value>) -> Option<Vec<ImageContent>> {
257    let value = obj.get("images").or_else(|| obj.get("attachments"))?;
258    if value.is_null() {
259        return Some(Vec::new());
260    }
261    serde_json::from_value(value.clone()).ok()
262}
263
264fn parse_input_event_images(
265    obj: &serde_json::Map<String, Value>,
266    fallback: Vec<ImageContent>,
267) -> Vec<ImageContent> {
268    parse_input_event_images_opt(obj).unwrap_or(fallback)
269}
270
271fn json_to_value<T: Serialize>(value: &T) -> Result<Value> {
272    serde_json::to_value(value).map_err(|err| Error::Json(Box::new(err)))
273}
274
275fn json_from_value<T: DeserializeOwned>(value: Value) -> Result<T> {
276    serde_json::from_value(value).map_err(|err| Error::Json(Box::new(err)))
277}
278
279/// Dispatches events to extension handlers.
280#[derive(Clone)]
281pub struct EventDispatcher {
282    runtime: ExtensionRuntimeHandle,
283}
284
285impl EventDispatcher {
286    #[must_use]
287    pub fn new<R>(runtime: R) -> Self
288    where
289        R: Into<ExtensionRuntimeHandle>,
290    {
291        Self {
292            runtime: runtime.into(),
293        }
294    }
295
296    /// Dispatch an event with an explicit context payload and timeout.
297    pub async fn dispatch_with_context<R: DeserializeOwned>(
298        &self,
299        event: ExtensionEvent,
300        ctx_payload: Value,
301        timeout_ms: u64,
302    ) -> Result<Option<R>> {
303        let event_name = event.event_name().to_string();
304        let event_payload = json_to_value(&event)?;
305        let response = self
306            .runtime
307            .dispatch_event(event_name, event_payload, Arc::new(ctx_payload), timeout_ms)
308            .await?;
309
310        if response.is_null() {
311            Ok(None)
312        } else {
313            Ok(Some(json_from_value(response)?))
314        }
315    }
316
317    /// Dispatch an event with an empty context payload and default timeout.
318    pub async fn dispatch<R: DeserializeOwned>(&self, event: ExtensionEvent) -> Result<Option<R>> {
319        self.dispatch_with_context(
320            event,
321            Value::Object(serde_json::Map::new()),
322            EXTENSION_EVENT_TIMEOUT_MS,
323        )
324        .await
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    use serde_json::json;
333
334    fn sample_images() -> Vec<ImageContent> {
335        vec![ImageContent {
336            data: "ORIGINAL_BASE64".to_string(),
337            mime_type: "image/png".to_string(),
338        }]
339    }
340
341    fn assert_continue(
342        outcome: InputEventOutcome,
343        expected_text: &str,
344        expected_images: &[ImageContent],
345    ) {
346        match outcome {
347            InputEventOutcome::Continue { text, images } => {
348                assert_eq!(text, expected_text);
349                assert_eq!(images.len(), expected_images.len());
350                for (actual, expected) in images.iter().zip(expected_images.iter()) {
351                    assert_eq!(actual.data, expected.data);
352                    assert_eq!(actual.mime_type, expected.mime_type);
353                }
354            }
355            InputEventOutcome::Block { reason } => {
356                panic!("expected continue, got block: {reason:?}");
357            }
358        }
359    }
360
361    #[test]
362    #[allow(clippy::too_many_lines)]
363    fn event_name_matches_expected_strings() {
364        fn sample_message() -> Message {
365            Message::Custom(crate::model::CustomMessage {
366                content: "hi".to_string(),
367                custom_type: "test".to_string(),
368                display: true,
369                details: None,
370                timestamp: 0,
371            })
372        }
373
374        fn sample_assistant_message() -> AssistantMessage {
375            AssistantMessage {
376                content: vec![ContentBlock::Text(crate::model::TextContent::new("ok"))],
377                api: "test".to_string(),
378                provider: "test".to_string(),
379                model: "test".to_string(),
380                usage: crate::model::Usage::default(),
381                stop_reason: crate::model::StopReason::Stop,
382                error_message: None,
383                timestamp: 0,
384            }
385        }
386
387        fn sample_tool_result() -> ToolResultMessage {
388            ToolResultMessage {
389                tool_call_id: "call-1".to_string(),
390                tool_name: "read".to_string(),
391                content: vec![ContentBlock::Text(crate::model::TextContent::new("ok"))],
392                details: None,
393                is_error: false,
394                timestamp: 0,
395            }
396        }
397
398        fn sample_image() -> ImageContent {
399            ImageContent {
400                data: "BASE64".to_string(),
401                mime_type: "image/png".to_string(),
402            }
403        }
404
405        let cases: Vec<(ExtensionEvent, &str)> = vec![
406            (
407                ExtensionEvent::Startup {
408                    version: "0.1.0".to_string(),
409                    session_file: None,
410                },
411                "startup",
412            ),
413            (
414                ExtensionEvent::AgentStart {
415                    session_id: "s".to_string(),
416                },
417                "agent_start",
418            ),
419            (
420                ExtensionEvent::AgentEnd {
421                    session_id: "s".to_string(),
422                    messages: vec![sample_message()],
423                    error: None,
424                },
425                "agent_end",
426            ),
427            (
428                ExtensionEvent::TurnStart {
429                    session_id: "s".to_string(),
430                    turn_index: 0,
431                },
432                "turn_start",
433            ),
434            (
435                ExtensionEvent::TurnEnd {
436                    session_id: "s".to_string(),
437                    turn_index: 0,
438                    message: sample_assistant_message(),
439                    tool_results: vec![sample_tool_result()],
440                },
441                "turn_end",
442            ),
443            (
444                ExtensionEvent::ToolCall {
445                    tool_name: "read".to_string(),
446                    tool_call_id: "call-1".to_string(),
447                    input: json!({ "path": "a.txt" }),
448                },
449                "tool_call",
450            ),
451            (
452                ExtensionEvent::ToolResult {
453                    tool_name: "read".to_string(),
454                    tool_call_id: "call-1".to_string(),
455                    input: json!({ "path": "a.txt" }),
456                    content: vec![ContentBlock::Text(crate::model::TextContent::new("ok"))],
457                    details: Some(json!({ "k": "v" })),
458                    is_error: false,
459                },
460                "tool_result",
461            ),
462            (
463                ExtensionEvent::SessionBeforeSwitch {
464                    current_session: None,
465                    target_session: "next".to_string(),
466                },
467                "session_before_switch",
468            ),
469            (
470                ExtensionEvent::SessionBeforeFork {
471                    current_session: Some("cur".to_string()),
472                    fork_entry_id: "entry-1".to_string(),
473                },
474                "session_before_fork",
475            ),
476            (
477                ExtensionEvent::Input {
478                    content: "hello".to_string(),
479                    attachments: vec![sample_image()],
480                },
481                "input",
482            ),
483        ];
484
485        for (event, expected) in cases {
486            assert_eq!(event.event_name(), expected);
487            let value = serde_json::to_value(&event).expect("serialize");
488            assert_eq!(value.get("type").and_then(Value::as_str), Some(expected));
489
490            // Verify camelCase fields match the actual protocol used by the agent
491            if expected == "input" {
492                assert!(
493                    value.get("text").is_some(),
494                    "Input event should have 'text' field"
495                );
496                assert!(
497                    value.get("images").is_some(),
498                    "Input event should have 'images' field"
499                );
500            } else if expected == "tool_call" {
501                assert!(
502                    value.get("toolName").is_some(),
503                    "ToolCall event should have 'toolName' field"
504                );
505                assert!(
506                    value.get("toolCallId").is_some(),
507                    "ToolCall event should have 'toolCallId' field"
508                );
509            } else if expected == "agent_start" {
510                assert!(
511                    value.get("sessionId").is_some(),
512                    "AgentStart event should have 'sessionId' field"
513                );
514            } else if expected == "turn_end" {
515                assert!(
516                    value.get("toolResults").is_some(),
517                    "TurnEnd event should have 'toolResults' field"
518                );
519            }
520        }
521    }
522
523    #[test]
524    fn result_types_deserialize_defaults() {
525        let result: ToolCallEventResult =
526            serde_json::from_value(json!({ "reason": "nope" })).expect("deserialize");
527        assert_eq!(
528            result,
529            ToolCallEventResult {
530                block: false,
531                reason: Some("nope".to_string())
532            }
533        );
534    }
535
536    #[test]
537    fn result_types_deserialize_all() {
538        let tool_call: ToolCallEventResult =
539            serde_json::from_value(json!({ "block": true })).expect("deserialize tool_call");
540        assert!(tool_call.block);
541        assert_eq!(tool_call.reason, None);
542
543        let tool_result: ToolResultEventResult = serde_json::from_value(json!({
544            "content": [{ "type": "text", "text": "hello" }],
545            "details": { "k": "v" }
546        }))
547        .expect("deserialize tool_result");
548        assert!(tool_result.content.is_some());
549        assert_eq!(tool_result.details, Some(json!({ "k": "v" })));
550
551        let input: InputEventResult =
552            serde_json::from_value(json!({ "content": "hi" })).expect("deserialize input");
553        assert_eq!(input.content.as_deref(), Some("hi"));
554        assert!(!input.block);
555        assert_eq!(input.reason, None);
556    }
557
558    #[test]
559    fn apply_input_event_response_preserves_original_for_none_and_null() {
560        let original_images = sample_images();
561
562        let none_response =
563            apply_input_event_response(None, "original".to_string(), original_images.clone());
564        assert_continue(none_response, "original", &original_images);
565
566        let null_response = apply_input_event_response(
567            Some(Value::Null),
568            "original".to_string(),
569            original_images.clone(),
570        );
571        assert_continue(null_response, "original", &original_images);
572    }
573
574    #[test]
575    fn apply_input_event_response_blocks_for_action_variants() {
576        for action in ["handled", "block", "blocked"] {
577            let outcome = apply_input_event_response(
578                Some(json!({ "action": action, "reason": "Denied by policy" })),
579                "original".to_string(),
580                sample_images(),
581            );
582
583            match outcome {
584                InputEventOutcome::Block { reason } => {
585                    assert_eq!(reason.as_deref(), Some("Denied by policy"));
586                }
587                InputEventOutcome::Continue { .. } => {
588                    panic!("expected block for action={action}");
589                }
590            }
591        }
592    }
593
594    #[test]
595    fn apply_input_event_response_transform_uses_overrides_and_fallbacks() {
596        let original_images = sample_images();
597        let override_images = vec![ImageContent {
598            data: "NEW_BASE64".to_string(),
599            mime_type: "image/jpeg".to_string(),
600        }];
601
602        let transformed = apply_input_event_response(
603            Some(json!({
604                "action": "transform",
605                "text": "rewritten",
606                "images": [{ "data": "NEW_BASE64", "mimeType": "image/jpeg" }]
607            })),
608            "original".to_string(),
609            original_images.clone(),
610        );
611        assert_continue(transformed, "rewritten", &override_images);
612
613        let invalid_images = apply_input_event_response(
614            Some(json!({
615                "action": "transform",
616                "text": "still rewritten",
617                "images": "not-an-array"
618            })),
619            "original".to_string(),
620            original_images.clone(),
621        );
622        assert_continue(invalid_images, "still rewritten", &original_images);
623
624        let null_images = apply_input_event_response(
625            Some(json!({
626                "content": "alt text",
627                "images": null
628            })),
629            "original".to_string(),
630            original_images,
631        );
632        assert_continue(null_images, "alt text", &[]);
633    }
634
635    #[test]
636    fn apply_input_event_response_continue_action_and_shorthand_string() {
637        let original_images = sample_images();
638
639        let explicit_continue = apply_input_event_response(
640            Some(json!({
641                "action": "continue",
642                "text": "ignored",
643                "images": []
644            })),
645            "original".to_string(),
646            original_images.clone(),
647        );
648        assert_continue(explicit_continue, "original", &original_images);
649
650        let shorthand = apply_input_event_response(
651            Some(Value::String("replacement".to_string())),
652            "original".to_string(),
653            original_images.clone(),
654        );
655        assert_continue(shorthand, "replacement", &original_images);
656    }
657
658    #[test]
659    fn apply_input_event_response_block_flag_and_message_fallback() {
660        let blocked = apply_input_event_response(
661            Some(json!({ "block": true, "message": "Policy denied" })),
662            "original".to_string(),
663            sample_images(),
664        );
665
666        match blocked {
667            InputEventOutcome::Block { reason } => {
668                assert_eq!(reason.as_deref(), Some("Policy denied"));
669            }
670            InputEventOutcome::Continue { .. } => panic!("expected block"),
671        }
672    }
673
674    // ── unknown action falls through to continue ───────────────────────
675
676    #[test]
677    fn apply_input_event_response_unknown_action_falls_through() {
678        let original_images = sample_images();
679        // Unknown action, no block flag, no text override → falls through to original
680        let outcome = apply_input_event_response(
681            Some(json!({ "action": "unknown_action" })),
682            "original".to_string(),
683            original_images.clone(),
684        );
685        assert_continue(outcome, "original", &original_images);
686    }
687
688    // ── non-object, non-string, non-null response ──────────────────────
689
690    #[test]
691    fn apply_input_event_response_number_returns_original() {
692        let original_images = sample_images();
693        let outcome = apply_input_event_response(
694            Some(json!(42)),
695            "original".to_string(),
696            original_images.clone(),
697        );
698        assert_continue(outcome, "original", &original_images);
699    }
700
701    #[test]
702    fn apply_input_event_response_boolean_returns_original() {
703        let original_images = sample_images();
704        let outcome = apply_input_event_response(
705            Some(json!(true)),
706            "original".to_string(),
707            original_images.clone(),
708        );
709        assert_continue(outcome, "original", &original_images);
710    }
711
712    #[test]
713    fn apply_input_event_response_array_returns_original() {
714        let original_images = sample_images();
715        let outcome = apply_input_event_response(
716            Some(json!([1, 2, 3])),
717            "original".to_string(),
718            original_images.clone(),
719        );
720        assert_continue(outcome, "original", &original_images);
721    }
722
723    // ── ToolCallEventResult default ────────────────────────────────────
724
725    #[test]
726    fn tool_call_event_result_default_is_not_blocked() {
727        let result = ToolCallEventResult::default();
728        assert!(!result.block);
729        assert!(result.reason.is_none());
730    }
731
732    // ── InputEventResult equality ──────────────────────────────────────
733
734    #[test]
735    fn input_event_result_equality() {
736        let a = InputEventResult {
737            content: Some("hello".to_string()),
738            block: false,
739            reason: None,
740        };
741        let b = InputEventResult {
742            content: Some("hello".to_string()),
743            block: false,
744            reason: None,
745        };
746        assert_eq!(a, b);
747    }
748
749    // ── transform with content key instead of text ─────────────────────
750
751    #[test]
752    fn apply_input_event_response_transform_uses_content_key() {
753        let original_images = sample_images();
754        let outcome = apply_input_event_response(
755            Some(json!({ "action": "transform", "content": "transformed via content" })),
756            "original".to_string(),
757            original_images.clone(),
758        );
759        assert_continue(outcome, "transformed via content", &original_images);
760    }
761
762    // ── text override without action ───────────────────────────────────
763
764    #[test]
765    fn apply_input_event_response_text_override_without_action() {
766        let original_images = sample_images();
767        let outcome = apply_input_event_response(
768            Some(json!({ "text": "overridden text" })),
769            "original".to_string(),
770            original_images.clone(),
771        );
772        assert_continue(outcome, "overridden text", &original_images);
773    }
774
775    // ── attachments key for images ─────────────────────────────────────
776
777    #[test]
778    fn apply_input_event_response_attachments_key_for_images() {
779        let outcome = apply_input_event_response(
780            Some(json!({
781                "text": "with attachments",
782                "attachments": [{ "data": "ATT_BASE64", "mimeType": "image/gif" }]
783            })),
784            "original".to_string(),
785            sample_images(),
786        );
787        match outcome {
788            InputEventOutcome::Continue { text, images } => {
789                assert_eq!(text, "with attachments");
790                assert_eq!(images.len(), 1);
791                assert_eq!(images[0].data, "ATT_BASE64");
792            }
793            InputEventOutcome::Block { .. } => panic!("expected continue"),
794        }
795    }
796
797    // ── block: false doesn't block ─────────────────────────────────────
798
799    #[test]
800    fn apply_input_event_response_block_false_does_not_block() {
801        let original_images = sample_images();
802        let outcome = apply_input_event_response(
803            Some(json!({ "block": false })),
804            "original".to_string(),
805            original_images.clone(),
806        );
807        assert_continue(outcome, "original", &original_images);
808    }
809
810    // ── empty object returns original ──────────────────────────────────
811
812    #[test]
813    fn apply_input_event_response_empty_object_returns_original() {
814        let original_images = sample_images();
815        let outcome = apply_input_event_response(
816            Some(json!({})),
817            "original".to_string(),
818            original_images.clone(),
819        );
820        assert_continue(outcome, "original", &original_images);
821    }
822
823    mod proptest_extension_events {
824        use super::*;
825        use proptest::prelude::*;
826
827        /// All event names are unique, lowercase, with underscores only.
828        const ALL_EVENT_NAMES: &[&str] = &[
829            "startup",
830            "agent_start",
831            "agent_end",
832            "turn_start",
833            "turn_end",
834            "tool_call",
835            "tool_result",
836            "session_before_switch",
837            "session_before_fork",
838            "input",
839        ];
840
841        proptest! {
842            /// `event_name` returns valid snake_case names.
843            #[test]
844            fn event_names_are_snake_case(idx in 0..ALL_EVENT_NAMES.len()) {
845                let name = ALL_EVENT_NAMES[idx];
846                assert!(
847                    name.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
848                    "not snake_case: {name}"
849                );
850                assert!(!name.is_empty());
851            }
852
853            /// `apply_input_event_response(None, ..)` always returns Continue with original.
854            #[test]
855            fn none_response_preserves_original(text in ".{0,50}") {
856                match apply_input_event_response(None, text.clone(), Vec::new()) {
857                    InputEventOutcome::Continue { text: t, images } => {
858                        assert_eq!(t, text);
859                        assert!(images.is_empty());
860                    }
861                    InputEventOutcome::Block { .. } => panic!("expected Continue"),
862                }
863            }
864
865            /// `apply_input_event_response(null, ..)` always returns Continue with original.
866            #[test]
867            fn null_response_preserves_original(text in ".{0,50}") {
868                match apply_input_event_response(Some(Value::Null), text.clone(), Vec::new()) {
869                    InputEventOutcome::Continue { text: t, images } => {
870                        assert_eq!(t, text);
871                        assert!(images.is_empty());
872                    }
873                    InputEventOutcome::Block { .. } => panic!("expected Continue"),
874                }
875            }
876
877            /// String response replaces text, preserves images.
878            #[test]
879            fn string_response_replaces_text(
880                original in "[a-z]{1,10}",
881                replacement in "[A-Z]{1,10}"
882            ) {
883                match apply_input_event_response(
884                    Some(Value::String(replacement.clone())),
885                    original,
886                    Vec::new(),
887                ) {
888                    InputEventOutcome::Continue { text, images } => {
889                        assert_eq!(text, replacement);
890                        assert!(images.is_empty());
891                    }
892                    InputEventOutcome::Block { .. } => panic!("expected Continue"),
893                }
894            }
895
896            /// "block" action always produces Block outcome.
897            #[test]
898            fn block_action_blocks(
899                action_idx in 0..3usize,
900                text in "[a-z]{1,10}"
901            ) {
902                let actions = ["handled", "block", "blocked"];
903                let response = json!({"action": actions[action_idx]});
904                match apply_input_event_response(Some(response), text, Vec::new()) {
905                    InputEventOutcome::Block { .. } => {}
906                    InputEventOutcome::Continue { .. } => {
907                        panic!("expected Block for action '{}'", actions[action_idx]);
908                    }
909                }
910            }
911
912            /// "continue" action preserves original text.
913            #[test]
914            fn continue_action_preserves(text in "[a-z]{1,20}") {
915                let response = json!({"action": "continue"});
916                match apply_input_event_response(Some(response), text.clone(), Vec::new()) {
917                    InputEventOutcome::Continue { text: t, .. } => {
918                        assert_eq!(t, text);
919                    }
920                    InputEventOutcome::Block { .. } => panic!("expected Continue"),
921                }
922            }
923
924            /// "transform" action with text field replaces text.
925            #[test]
926            fn transform_replaces_text(
927                original in "[a-z]{1,10}",
928                new_text in "[A-Z]{1,10}"
929            ) {
930                let response = json!({"action": "transform", "text": &new_text});
931                match apply_input_event_response(Some(response), original, Vec::new()) {
932                    InputEventOutcome::Continue { text, .. } => {
933                        assert_eq!(text, new_text);
934                    }
935                    InputEventOutcome::Block { .. } => panic!("expected Continue"),
936                }
937            }
938
939            /// `block: true` without action produces Block.
940            #[test]
941            fn block_true_flag_blocks(text in "[a-z]{1,10}") {
942                let response = json!({"block": true});
943                match apply_input_event_response(Some(response), text, Vec::new()) {
944                    InputEventOutcome::Block { .. } => {}
945                    InputEventOutcome::Continue { .. } => panic!("expected Block"),
946                }
947            }
948
949            /// `block: false` without action returns Continue.
950            #[test]
951            fn block_false_continues(text in "[a-z]{1,10}") {
952                let response = json!({"block": false});
953                match apply_input_event_response(Some(response), text.clone(), Vec::new()) {
954                    InputEventOutcome::Continue { text: t, .. } => {
955                        assert_eq!(t, text);
956                    }
957                    InputEventOutcome::Block { .. } => panic!("expected Continue"),
958                }
959            }
960
961            /// Non-object, non-string, non-null values preserve original.
962            #[test]
963            fn numeric_response_preserves(n in -100i64..100, text in "[a-z]{1,10}") {
964                let response = Value::from(n);
965                match apply_input_event_response(Some(response), text.clone(), Vec::new()) {
966                    InputEventOutcome::Continue { text: t, .. } => {
967                        assert_eq!(t, text);
968                    }
969                    InputEventOutcome::Block { .. } => panic!("expected Continue"),
970                }
971            }
972
973            /// Block reason is extracted from "reason" or "message" field.
974            #[test]
975            fn block_reason_extracted(
976                reason in "[a-z]{1,20}",
977                use_message_key in proptest::bool::ANY
978            ) {
979                let key = if use_message_key { "message" } else { "reason" };
980                let response = json!({"action": "block", key: &reason});
981                match apply_input_event_response(Some(response), String::new(), Vec::new()) {
982                    InputEventOutcome::Block { reason: r } => {
983                        assert_eq!(r.as_deref(), Some(reason.as_str()));
984                    }
985                    InputEventOutcome::Continue { .. } => panic!("expected Block"),
986                }
987            }
988
989            /// `ToolCallEventResult` deserializes with correct defaults.
990            #[test]
991            fn tool_call_result_deserialize(
992                block in proptest::bool::ANY,
993                reason in prop::option::of("[a-z ]{1,30}")
994            ) {
995                let mut obj = serde_json::Map::new();
996                obj.insert("block".to_string(), json!(block));
997                if let Some(ref r) = reason {
998                    obj.insert("reason".to_string(), json!(r));
999                }
1000                let back: ToolCallEventResult =
1001                    serde_json::from_value(Value::Object(obj)).unwrap();
1002                assert_eq!(back.block, block);
1003                assert_eq!(back.reason, reason);
1004            }
1005
1006            /// `ToolCallEventResult` default has block=false.
1007            #[test]
1008            fn tool_call_result_default(_dummy in 0..1u8) {
1009                let d = ToolCallEventResult::default();
1010                assert!(!d.block);
1011                assert!(d.reason.is_none());
1012            }
1013
1014            /// `InputEventResult` deserializes correctly.
1015            #[test]
1016            fn input_event_result_deserialize(
1017                content in prop::option::of("[a-z]{1,20}"),
1018                block in proptest::bool::ANY,
1019                reason in prop::option::of("[a-z]{1,20}")
1020            ) {
1021                let mut obj = serde_json::Map::new();
1022                if let Some(ref c) = content {
1023                    obj.insert("content".to_string(), json!(c));
1024                }
1025                obj.insert("block".to_string(), json!(block));
1026                if let Some(ref r) = reason {
1027                    obj.insert("reason".to_string(), json!(r));
1028                }
1029                let back: InputEventResult =
1030                    serde_json::from_value(Value::Object(obj)).unwrap();
1031                assert_eq!(back.content, content);
1032                assert_eq!(back.block, block);
1033                assert_eq!(back.reason, reason);
1034            }
1035        }
1036    }
1037}