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