1use 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#[derive(Debug, Clone, Serialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum ExtensionEvent {
24 #[serde(rename_all = "camelCase")]
26 Startup {
27 version: String,
28 session_file: Option<String>,
29 },
30
31 #[serde(rename_all = "camelCase")]
33 AgentStart { session_id: String },
34
35 #[serde(rename_all = "camelCase")]
37 AgentEnd {
38 session_id: String,
39 messages: Vec<Message>,
40 error: Option<String>,
41 },
42
43 #[serde(rename_all = "camelCase")]
45 TurnStart {
46 session_id: String,
47 turn_index: usize,
48 },
49
50 #[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 #[serde(rename_all = "camelCase")]
61 ToolCall {
62 tool_name: String,
63 tool_call_id: String,
64 input: Value,
65 },
66
67 #[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 #[serde(rename_all = "camelCase")]
80 SessionBeforeSwitch {
81 current_session: Option<String>,
82 target_session: String,
83 },
84
85 #[serde(rename_all = "camelCase")]
87 SessionBeforeFork {
88 current_session: Option<String>,
89 fork_entry_id: String,
90 },
91
92 #[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 #[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#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "camelCase")]
124pub struct ToolCallEventResult {
125 #[serde(default)]
127 pub block: bool,
128
129 pub reason: Option<String>,
131}
132
133#[derive(Debug, Clone, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct ToolResultEventResult {
137 pub content: Option<Vec<ContentBlock>>,
139
140 pub details: Option<Value>,
142}
143
144#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "camelCase")]
147pub struct InputEventResult {
148 pub content: Option<String>,
150
151 #[serde(default)]
153 pub block: bool,
154
155 pub reason: Option<String>,
157}
158
159#[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#[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 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 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 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 #[test]
850 fn apply_input_event_response_unknown_action_falls_through() {
851 let original_images = sample_images();
852 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}