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::{AssistantMessage, ContentBlock, ImageContent, Message, ToolResultMessage};
14
15#[derive(Debug, Clone, Serialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum ExtensionEvent {
22 #[serde(rename_all = "camelCase")]
24 Startup {
25 version: String,
26 session_file: Option<String>,
27 },
28
29 #[serde(rename_all = "camelCase")]
31 AgentStart { session_id: String },
32
33 #[serde(rename_all = "camelCase")]
35 AgentEnd {
36 session_id: String,
37 messages: Vec<Message>,
38 error: Option<String>,
39 },
40
41 #[serde(rename_all = "camelCase")]
43 TurnStart {
44 session_id: String,
45 turn_index: usize,
46 },
47
48 #[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 #[serde(rename_all = "camelCase")]
59 ToolCall {
60 tool_name: String,
61 tool_call_id: String,
62 input: Value,
63 },
64
65 #[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 #[serde(rename_all = "camelCase")]
78 SessionBeforeSwitch {
79 current_session: Option<String>,
80 target_session: String,
81 },
82
83 #[serde(rename_all = "camelCase")]
85 SessionBeforeFork {
86 current_session: Option<String>,
87 fork_entry_id: String,
88 },
89
90 #[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 #[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#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "camelCase")]
122pub struct ToolCallEventResult {
123 #[serde(default)]
125 pub block: bool,
126
127 pub reason: Option<String>,
129}
130
131#[derive(Debug, Clone, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct ToolResultEventResult {
135 pub content: Option<Vec<ContentBlock>>,
137
138 pub details: Option<Value>,
140}
141
142#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "camelCase")]
145pub struct InputEventResult {
146 pub content: Option<String>,
148
149 #[serde(default)]
151 pub block: bool,
152
153 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#[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 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 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 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 #[test]
677 fn apply_input_event_response_unknown_action_falls_through() {
678 let original_images = sample_images();
679 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}