1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum InteractionKind {
17 FreeText,
19 MultipleChoice,
21 Confirm,
23 ToolApproval,
25 EditText,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum BodyFormat {
34 #[default]
36 Plain,
37 Markdown,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct InteractionRequest {
44 pub id: String,
46 pub kind: InteractionKind,
48 pub prompt: String,
50 #[serde(default)]
52 pub options: Vec<String>,
53 pub tool_name: Option<String>,
55 pub tool_arguments: Option<serde_json::Value>,
57 #[serde(default = "crate::default_true")]
59 pub required: bool,
60 pub stage_name: String,
62 #[serde(default)]
64 pub body: Option<String>,
65 #[serde(default)]
67 pub body_format: BodyFormat,
68}
69
70impl InteractionRequest {
71 pub fn free_text(
73 id: impl Into<String>,
74 prompt: impl Into<String>,
75 stage: impl Into<String>,
76 required: bool,
77 ) -> Self {
78 Self {
79 id: id.into(),
80 kind: InteractionKind::FreeText,
81 prompt: prompt.into(),
82 options: vec![],
83 tool_name: None,
84 tool_arguments: None,
85 required,
86 stage_name: stage.into(),
87 body: None,
88 body_format: BodyFormat::Plain,
89 }
90 }
91
92 pub fn review(
95 id: impl Into<String>,
96 title: impl Into<String>,
97 markdown: impl Into<String>,
98 stage: impl Into<String>,
99 ) -> Self {
100 Self {
101 id: id.into(),
102 kind: InteractionKind::FreeText,
103 prompt: title.into(),
104 options: vec![],
105 tool_name: None,
106 tool_arguments: None,
107 required: true,
108 stage_name: stage.into(),
109 body: Some(markdown.into()),
110 body_format: BodyFormat::Markdown,
111 }
112 }
113
114 pub fn edit_text(
118 id: impl Into<String>,
119 prompt: impl Into<String>,
120 stage: impl Into<String>,
121 initial_content: impl Into<String>,
122 ) -> Self {
123 Self {
124 id: id.into(),
125 kind: InteractionKind::EditText,
126 prompt: prompt.into(),
127 options: vec![],
128 tool_name: None,
129 tool_arguments: None,
130 required: true,
131 stage_name: stage.into(),
132 body: Some(initial_content.into()),
133 body_format: BodyFormat::Plain,
134 }
135 }
136
137 pub fn multiple_choice(
139 id: impl Into<String>,
140 prompt: impl Into<String>,
141 options: Vec<String>,
142 stage: impl Into<String>,
143 ) -> Self {
144 Self {
145 id: id.into(),
146 kind: InteractionKind::MultipleChoice,
147 prompt: prompt.into(),
148 options,
149 tool_name: None,
150 tool_arguments: None,
151 required: true,
152 stage_name: stage.into(),
153 body: None,
154 body_format: BodyFormat::Plain,
155 }
156 }
157
158 pub fn confirm(
160 id: impl Into<String>,
161 prompt: impl Into<String>,
162 stage: impl Into<String>,
163 ) -> Self {
164 Self {
165 id: id.into(),
166 kind: InteractionKind::Confirm,
167 prompt: prompt.into(),
168 options: vec!["Yes".to_string(), "No".to_string()],
169 tool_name: None,
170 tool_arguments: None,
171 required: true,
172 stage_name: stage.into(),
173 body: None,
174 body_format: BodyFormat::Plain,
175 }
176 }
177
178 fn approval_detail(tool: &str, arguments: &serde_json::Value) -> Option<String> {
190 let field = match tool {
191 "bash" | "shell" => "command",
192 "write_file" | "edit_file" | "read_file" => "path",
193 _ => return None,
194 };
195 let raw = arguments.get(field)?.as_str()?.trim();
196 if raw.is_empty() {
197 return None;
198 }
199 let first = raw.lines().next().unwrap_or(raw);
202 let mut shown: String = first.chars().take(120).collect();
203 if shown.chars().count() < first.chars().count() || first.len() < raw.len() {
204 shown.push('…');
205 }
206 Some(format!("`{shown}`"))
207 }
208
209 pub fn gate_approval(
216 id: impl Into<String>,
217 tool_name: impl Into<String>,
218 arguments: serde_json::Value,
219 stage: impl Into<String>,
220 ) -> Self {
221 let tool = tool_name.into();
222 Self {
223 id: id.into(),
224 kind: InteractionKind::ToolApproval,
225 prompt: format!("Allow tool call: `{tool}`?"),
226 options: vec![
227 "Allow once".to_string(),
228 "Allow for this run".to_string(),
229 "Deny".to_string(),
230 ],
231 tool_name: Some(tool),
232 tool_arguments: Some(arguments),
233 required: true,
234 stage_name: stage.into(),
235 body: None,
236 body_format: BodyFormat::Plain,
237 }
238 }
239
240 fn grant_summary(grant_keys: &[String]) -> Option<String> {
247 if grant_keys.is_empty() {
248 return None;
249 }
250 let named: Vec<&str> = grant_keys
251 .iter()
252 .take(3)
253 .map(|k| k.strip_prefix("shell:").unwrap_or(k))
254 .collect();
255 let mut summary = named.join(", ");
256 if grant_keys.len() > named.len() {
257 summary.push_str(&format!(" +{} more", grant_keys.len() - named.len()));
258 }
259 Some(summary)
260 }
261
262 pub fn tool_approval(
275 id: impl Into<String>,
276 tool_name: impl Into<String>,
277 arguments: serde_json::Value,
278 stage: impl Into<String>,
279 grant_keys: &[String],
280 ) -> Self {
281 let tool = tool_name.into();
282 let prompt = match Self::approval_detail(&tool, &arguments) {
283 Some(detail) => format!("Allow tool call: `{tool}` - {detail}?"),
284 None => format!("Allow tool call: `{tool}`?"),
285 };
286 let (stage_label, run_label) = match Self::grant_summary(grant_keys) {
287 Some(what) => (
288 format!("Allow {what} for this stage"),
289 format!("Allow {what} for this run"),
290 ),
291 None => (
292 "Allow for this stage (nothing reusable - it will ask again)".to_string(),
293 "Allow for this run (nothing reusable - it will ask again)".to_string(),
294 ),
295 };
296 Self {
297 id: id.into(),
298 kind: InteractionKind::ToolApproval,
299 prompt,
300 options: vec![
301 "Allow once".to_string(),
302 stage_label,
303 run_label,
304 "Deny".to_string(),
305 DENY_WITH_FEEDBACK.to_string(),
306 ],
307 tool_name: Some(tool),
308 tool_arguments: Some(arguments),
309 required: true,
310 stage_name: stage.into(),
311 body: None,
312 body_format: BodyFormat::Plain,
313 }
314 }
315
316 pub fn is_deny_with_feedback(&self, index: usize) -> bool {
325 self.kind == InteractionKind::ToolApproval
326 && self.options.get(index).map(String::as_str) == Some(DENY_WITH_FEEDBACK)
327 }
328}
329
330pub const DENY_WITH_FEEDBACK: &str = "Deny with feedback";
338
339pub fn approval_choice(index: usize) -> Option<ApprovalScope> {
346 match index {
347 0 => Some(ApprovalScope::Once),
348 1 => Some(ApprovalScope::Stage),
349 2 => Some(ApprovalScope::Run),
350 _ => None,
351 }
352}
353
354#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
367#[serde(rename_all = "snake_case")]
368pub enum ApprovalScope {
369 Once,
371 Stage,
373 #[serde(rename = "session", alias = "run")]
379 Run,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
384pub struct InteractionResponse {
385 pub request_id: String,
387 pub value: Option<String>,
389 pub choice_index: Option<usize>,
391 pub approved: Option<bool>,
393 pub scope: Option<ApprovalScope>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub feedback: Option<String>,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
410 pub parts: Vec<crate::mime::InboundPart>,
411}
412
413impl InteractionResponse {
414 pub fn with_parts(mut self, parts: Vec<crate::mime::InboundPart>) -> Self {
416 self.parts = parts;
417 self
418 }
419
420 pub fn text(request_id: impl Into<String>, value: impl Into<String>) -> Self {
422 Self {
423 request_id: request_id.into(),
424 value: Some(value.into()),
425 choice_index: None,
426 approved: None,
427 scope: None,
428 feedback: None,
429 parts: Vec::new(),
430 }
431 }
432
433 pub fn choice(request_id: impl Into<String>, index: usize) -> Self {
435 Self {
436 request_id: request_id.into(),
437 value: None,
438 choice_index: Some(index),
439 approved: None,
440 scope: None,
441 feedback: None,
442 parts: Vec::new(),
443 }
444 }
445
446 pub fn approval(request_id: impl Into<String>, approved: bool, scope: ApprovalScope) -> Self {
448 Self {
449 request_id: request_id.into(),
450 value: None,
451 choice_index: None,
452 approved: Some(approved),
453 scope: Some(scope),
454 feedback: None,
455 parts: Vec::new(),
456 }
457 }
458
459 pub fn deny_with_feedback(request_id: impl Into<String>, feedback: &str) -> Self {
465 let feedback = feedback.trim();
466 Self {
467 request_id: request_id.into(),
468 value: None,
469 choice_index: None,
470 approved: Some(false),
471 scope: Some(ApprovalScope::Once),
472 feedback: (!feedback.is_empty()).then(|| feedback.to_string()),
473 parts: Vec::new(),
474 }
475 }
476
477 pub fn deny_feedback(&self) -> Option<&str> {
483 match self.approved {
484 Some(false) => self
485 .feedback
486 .as_deref()
487 .map(str::trim)
488 .filter(|f| !f.is_empty()),
489 _ => None,
490 }
491 }
492}
493
494pub fn response_as_text(resp: &InteractionResponse) -> String {
498 resp.value.clone().unwrap_or_default()
499}
500
501pub fn response_as_choice<'a>(
503 resp: &InteractionResponse,
504 options: &'a [String],
505) -> Option<&'a String> {
506 resp.choice_index.and_then(|i| options.get(i))
507}
508
509pub fn response_approved(resp: &InteractionResponse) -> bool {
511 resp.approved.unwrap_or(false)
512}
513
514pub fn make_interaction_id(stage_idx: usize, iteration: usize) -> String {
516 format!("{}-{}", stage_idx, iteration)
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
527 fn a_tool_approval_says_what_it_is_asking_about() {
528 let req = InteractionRequest::tool_approval(
529 "id",
530 "bash",
531 serde_json::json!({"command": "rm -rf build && make"}),
532 "implement",
533 &[],
534 );
535 assert!(
536 req.prompt.contains("rm -rf build && make"),
537 "{}",
538 req.prompt
539 );
540
541 let req = InteractionRequest::tool_approval(
543 "id",
544 "write_file",
545 serde_json::json!({"path": "src/main.rs", "content": "..."}),
546 "implement",
547 &[],
548 );
549 assert!(req.prompt.contains("src/main.rs"), "{}", req.prompt);
550 }
551
552 #[test]
555 fn a_long_or_multiline_command_is_summarised() {
556 let long = "echo ".to_string() + &"x".repeat(400);
557 let req = InteractionRequest::tool_approval(
558 "id",
559 "bash",
560 serde_json::json!({ "command": long }),
561 "s",
562 &[],
563 );
564 assert!(req.prompt.chars().count() < 200, "{}", req.prompt);
565 assert!(req.prompt.contains('…'), "{}", req.prompt);
566
567 let req = InteractionRequest::tool_approval(
568 "id",
569 "bash",
570 serde_json::json!({"command": "cat <<'EOF' > f\nline two\nEOF"}),
571 "s",
572 &[],
573 );
574 assert!(req.prompt.contains("cat <<'EOF' > f"), "{}", req.prompt);
575 assert!(!req.prompt.contains("line two"), "{}", req.prompt);
576 assert!(req.tool_arguments.is_some());
578 }
579
580 #[test]
583 fn a_tool_without_a_telling_argument_reads_as_before() {
584 let req =
585 InteractionRequest::tool_approval("id", "list_dir", serde_json::json!({}), "s", &[]);
586 assert_eq!(req.prompt, "Allow tool call: `list_dir`?");
587 let req = InteractionRequest::tool_approval(
588 "id",
589 "bash",
590 serde_json::json!({"command": " "}),
591 "s",
592 &[],
593 );
594 assert_eq!(req.prompt, "Allow tool call: `bash`?");
595 let req = InteractionRequest::tool_approval(
597 "id",
598 "bash",
599 serde_json::json!({"command": 42}),
600 "s",
601 &[],
602 );
603 assert_eq!(req.prompt, "Allow tool call: `bash`?");
604 }
605
606 #[test]
611 fn the_scope_options_name_what_they_grant() {
612 let keys = |names: &[&str]| -> Vec<String> {
613 names.iter().map(|n| format!("shell:{n}")).collect()
614 };
615 let req = InteractionRequest::tool_approval(
616 "id",
617 "shell",
618 serde_json::json!({"command": "ls && git status"}),
619 "s",
620 &keys(&["git status", "ls"]),
621 );
622 assert_eq!(req.options[1], "Allow git status, ls for this stage");
623 assert_eq!(req.options[2], "Allow git status, ls for this run");
624
625 let req = InteractionRequest::tool_approval(
627 "id",
628 "shell",
629 serde_json::json!({}),
630 "s",
631 &keys(&["a", "b", "c", "d", "e"]),
632 );
633 assert_eq!(req.options[2], "Allow a, b, c +2 more for this run");
634
635 let req = InteractionRequest::tool_approval(
637 "id",
638 "web_fetch",
639 serde_json::json!({}),
640 "s",
641 &["web_fetch".to_string()],
642 );
643 assert_eq!(req.options[2], "Allow web_fetch for this run");
644 }
645
646 #[test]
649 fn an_unkeyable_call_says_it_will_ask_again() {
650 let req = InteractionRequest::tool_approval(
651 "id",
652 "shell",
653 serde_json::json!({"command": "echo `whoami`"}),
654 "s",
655 &[],
656 );
657 assert!(
658 req.options[1].contains("it will ask again"),
659 "{:?}",
660 req.options
661 );
662 assert!(
663 req.options[2].contains("it will ask again"),
664 "{:?}",
665 req.options
666 );
667 }
668
669 #[test]
672 fn approval_choice_matches_the_option_order() {
673 let req = InteractionRequest::tool_approval("id", "shell", serde_json::json!({}), "s", &[]);
674 assert_eq!(approval_choice(0), Some(ApprovalScope::Once));
675 assert!(req.options[1].contains("stage"));
676 assert_eq!(approval_choice(1), Some(ApprovalScope::Stage));
677 assert!(req.options[2].contains("run"));
678 assert_eq!(approval_choice(2), Some(ApprovalScope::Run));
679 assert_eq!(req.options[3], "Deny");
680 assert_eq!(approval_choice(3), None);
681 assert_eq!(req.options[4], DENY_WITH_FEEDBACK);
682 assert_eq!(approval_choice(4), None, "feedback is still a deny");
683 assert_eq!(
684 approval_choice(99),
685 None,
686 "an unknown answer must not approve"
687 );
688 }
689
690 #[test]
693 fn a_gate_approval_offers_run_scope_and_no_stage_scope() {
694 let req =
695 InteractionRequest::gate_approval("g", "web_fetch", serde_json::json!({}), "research");
696 assert_eq!(req.kind, InteractionKind::ToolApproval);
697 assert_eq!(req.prompt, "Allow tool call: `web_fetch`?");
698 assert_eq!(req.options, ["Allow once", "Allow for this run", "Deny"]);
699 }
700
701 #[test]
704 fn run_scope_serialises_as_session() {
705 assert_eq!(
706 serde_json::to_string(&ApprovalScope::Run).unwrap(),
707 "\"session\""
708 );
709 for wire in ["\"session\"", "\"run\""] {
710 let back: ApprovalScope = serde_json::from_str(wire).unwrap();
711 assert_eq!(back, ApprovalScope::Run, "{wire}");
712 }
713 assert_eq!(
714 serde_json::to_string(&ApprovalScope::Stage).unwrap(),
715 "\"stage\""
716 );
717 }
718
719 #[test]
722 fn test_request_builders() {
723 let r = InteractionRequest::free_text("id1", "What now?", "plan", true);
724 assert_eq!(r.kind, InteractionKind::FreeText);
725 assert!(r.required);
726
727 let r = InteractionRequest::multiple_choice(
728 "id2",
729 "Pick one",
730 vec!["A".into(), "B".into()],
731 "plan",
732 );
733 assert_eq!(r.kind, InteractionKind::MultipleChoice);
734 assert_eq!(r.options.len(), 2);
735
736 let r = InteractionRequest::tool_approval(
737 "id3",
738 "bash",
739 serde_json::json!({"cmd": "ls"}),
740 "impl",
741 &[],
742 );
743 assert_eq!(r.kind, InteractionKind::ToolApproval);
744 assert_eq!(r.options.len(), 5);
745 }
746
747 #[test]
748 fn test_edit_text_request_builder_seeds_body() {
749 let r = InteractionRequest::edit_text("id4", "Edit this", "plan", "current text");
750 assert_eq!(r.kind, InteractionKind::EditText);
751 assert!(r.required);
752 assert_eq!(r.body.as_deref(), Some("current text"));
753 assert_eq!(r.prompt, "Edit this");
754 }
755
756 #[test]
757 fn test_edit_text_kind_serde_roundtrip_snake_case() {
758 let r = InteractionRequest::edit_text("id5", "p", "plan", "seed");
759 let json = serde_json::to_string(&r).unwrap();
760 assert!(json.contains("\"edit_text\""));
762 let back: InteractionRequest = serde_json::from_str(&json).unwrap();
763 assert_eq!(back.kind, InteractionKind::EditText);
764 assert_eq!(back.body.as_deref(), Some("seed"));
765 }
766
767 #[test]
768 fn test_response_builders() {
769 let r = InteractionResponse::text("id1", "hello");
770 assert_eq!(r.value.as_deref(), Some("hello"));
771
772 let r = InteractionResponse::choice("id2", 1);
773 assert_eq!(r.choice_index, Some(1));
774
775 let r = InteractionResponse::approval("id3", true, ApprovalScope::Run);
776 assert_eq!(r.approved, Some(true));
777 assert_eq!(r.scope, Some(ApprovalScope::Run));
778 }
779
780 #[test]
783 fn parts_ride_the_answer_only_when_attached() {
784 let bare = InteractionResponse::text("q1", "hi");
785 let json = serde_json::to_string(&bare).unwrap();
786 assert!(!json.contains("parts"), "{json}");
787 let parsed: InteractionResponse = serde_json::from_str(&json).unwrap();
788 assert!(parsed.parts.is_empty());
789 let with = bare.with_parts(vec![crate::mime::InboundPart::from_bytes(
790 "a.png",
791 vec![1, 2, 3],
792 )]);
793 let json = serde_json::to_string(&with).unwrap();
794 assert!(json.contains("\"parts\""), "{json}");
795 let parsed: InteractionResponse = serde_json::from_str(&json).unwrap();
796 assert_eq!(parsed.parts.len(), 1);
797 assert_eq!(parsed.parts[0].name, "a.png");
798 }
799
800 #[test]
801 fn test_response_as_text() {
802 let r = InteractionResponse::text("id", "answer");
803 assert_eq!(response_as_text(&r), "answer");
804 let empty = InteractionResponse {
805 request_id: "x".into(),
806 value: None,
807 choice_index: None,
808 approved: None,
809 scope: None,
810 feedback: None,
811 parts: Vec::new(),
812 };
813 assert_eq!(response_as_text(&empty), "");
814 }
815
816 #[test]
817 fn test_response_as_choice() {
818 let opts = vec!["Alpha".to_string(), "Beta".to_string()];
819 let r = InteractionResponse::choice("id", 0);
820 assert_eq!(response_as_choice(&r, &opts), Some(&"Alpha".to_string()));
821 let r = InteractionResponse::choice("id", 1);
822 assert_eq!(response_as_choice(&r, &opts), Some(&"Beta".to_string()));
823 let r = InteractionResponse::choice("id", 99);
824 assert!(response_as_choice(&r, &opts).is_none());
825 }
826
827 #[test]
828 fn test_make_interaction_id() {
829 let id = make_interaction_id(2, 5);
830 assert_eq!(id, "2-5");
831 }
832
833 #[test]
834 fn test_free_text_request_not_required() {
835 let r = InteractionRequest::free_text("ft1", "optional?", "stage1", false);
836 assert_eq!(r.kind, InteractionKind::FreeText);
837 assert!(!r.required);
838 assert_eq!(r.id, "ft1");
839 assert_eq!(r.prompt, "optional?");
840 assert_eq!(r.stage_name, "stage1");
841 assert!(r.options.is_empty());
842 assert!(r.tool_name.is_none());
843 assert!(r.tool_arguments.is_none());
844 assert!(r.body.is_none());
845 assert_eq!(r.body_format, BodyFormat::Plain);
846 }
847
848 #[test]
849 fn test_review_request() {
850 let r = InteractionRequest::review("rev1", "Review Title", "# Markdown body", "plan");
851 assert_eq!(r.kind, InteractionKind::FreeText);
852 assert!(r.required);
853 assert_eq!(r.prompt, "Review Title");
854 assert_eq!(r.body.as_deref(), Some("# Markdown body"));
855 assert_eq!(r.body_format, BodyFormat::Markdown);
856 assert_eq!(r.stage_name, "plan");
857 }
858
859 #[test]
860 fn test_confirm_request() {
861 let r = InteractionRequest::confirm("c1", "Proceed?", "deploy");
862 assert_eq!(r.kind, InteractionKind::Confirm);
863 assert_eq!(r.options, vec!["Yes", "No"]);
864 assert!(r.required);
865 assert_eq!(r.stage_name, "deploy");
866 }
867
868 #[test]
869 fn test_tool_approval_request() {
870 let args = serde_json::json!({"file": "test.txt"});
871 let r = InteractionRequest::tool_approval("ta1", "write_file", args, "code", &[]);
872 assert_eq!(r.kind, InteractionKind::ToolApproval);
873 assert_eq!(r.tool_name.as_deref(), Some("write_file"));
874 assert!(r.tool_arguments.is_some());
875 assert_eq!(r.options.len(), 5);
876 assert!(r.prompt.contains("write_file"));
877 }
878
879 #[test]
880 fn test_response_text_empty() {
881 let r = InteractionResponse::text("id", "");
882 assert_eq!(r.value.as_deref(), Some(""));
883 assert!(r.choice_index.is_none());
884 assert!(r.approved.is_none());
885 assert!(r.scope.is_none());
886 }
887
888 #[test]
889 fn test_response_approval_denied() {
890 let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
891 assert_eq!(r.approved, Some(false));
892 assert_eq!(r.scope, Some(ApprovalScope::Once));
893 }
894
895 #[test]
896 fn test_response_approved_true() {
897 let r = InteractionResponse::approval("id", true, ApprovalScope::Run);
898 assert!(response_approved(&r));
899 }
900
901 #[test]
902 fn test_response_approved_false() {
903 let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
904 assert!(!response_approved(&r));
905 }
906
907 #[test]
908 fn test_response_approved_none() {
909 let r = InteractionResponse::text("id", "hello");
910 assert!(!response_approved(&r));
911 }
912
913 #[test]
914 fn test_approval_scope_serde_roundtrip() {
915 for scope in [ApprovalScope::Once, ApprovalScope::Run] {
916 let json = serde_json::to_string(&scope).unwrap();
917 let back: ApprovalScope = serde_json::from_str(&json).unwrap();
918 assert_eq!(scope, back);
919 }
920 }
921
922 #[test]
923 fn test_approval_scope_snake_case() {
924 let json = serde_json::to_string(&ApprovalScope::Once).unwrap();
925 assert_eq!(json, "\"once\"");
926 let json = serde_json::to_string(&ApprovalScope::Run).unwrap();
927 assert_eq!(json, "\"session\"");
928 }
929
930 #[test]
931 fn test_interaction_kind_serde_roundtrip() {
932 for kind in [
933 InteractionKind::FreeText,
934 InteractionKind::MultipleChoice,
935 InteractionKind::Confirm,
936 InteractionKind::ToolApproval,
937 ] {
938 let json = serde_json::to_string(&kind).unwrap();
939 let back: InteractionKind = serde_json::from_str(&json).unwrap();
940 assert_eq!(kind, back);
941 }
942 }
943
944 #[test]
945 fn test_body_format_serde_roundtrip() {
946 for fmt in [BodyFormat::Plain, BodyFormat::Markdown] {
947 let json = serde_json::to_string(&fmt).unwrap();
948 let back: BodyFormat = serde_json::from_str(&json).unwrap();
949 assert_eq!(fmt, back);
950 }
951 }
952
953 #[test]
954 fn test_body_format_default_is_plain() {
955 let fmt = BodyFormat::default();
956 assert_eq!(fmt, BodyFormat::Plain);
957 }
958
959 #[test]
960 fn test_interaction_request_serde_roundtrip() {
961 let req = InteractionRequest::tool_approval(
962 "serde1",
963 "bash",
964 serde_json::json!({"cmd": "ls -la"}),
965 "code",
966 &[],
967 );
968 let json = serde_json::to_string(&req).unwrap();
969 let back: InteractionRequest = serde_json::from_str(&json).unwrap();
970 assert_eq!(back.id, "serde1");
971 assert_eq!(back.kind, InteractionKind::ToolApproval);
972 assert_eq!(back.tool_name.as_deref(), Some("bash"));
973 }
974
975 #[test]
976 fn test_interaction_response_serde_roundtrip() {
977 let resp = InteractionResponse::approval("serde2", true, ApprovalScope::Run);
978 let json = serde_json::to_string(&resp).unwrap();
979 let back: InteractionResponse = serde_json::from_str(&json).unwrap();
980 assert_eq!(back.request_id, "serde2");
981 assert_eq!(back.approved, Some(true));
982 assert_eq!(back.scope, Some(ApprovalScope::Run));
983 }
984
985 #[test]
986 fn test_make_interaction_id_zero() {
987 assert_eq!(make_interaction_id(0, 0), "0-0");
988 }
989
990 #[test]
991 fn test_make_interaction_id_large() {
992 assert_eq!(make_interaction_id(999, 1000), "999-1000");
993 }
994
995 #[test]
996 fn test_response_as_choice_no_choice_index() {
997 let opts = vec!["A".to_string(), "B".to_string()];
998 let r = InteractionResponse::text("id", "hello");
999 assert!(response_as_choice(&r, &opts).is_none());
1000 }
1001
1002 #[test]
1003 fn test_response_as_choice_empty_options() {
1004 let opts: Vec<String> = vec![];
1005 let r = InteractionResponse::choice("id", 0);
1006 assert!(response_as_choice(&r, &opts).is_none());
1007 }
1008
1009 #[test]
1010 fn test_free_text_request_defaults() {
1011 let r = InteractionRequest::free_text("ft", "prompt", "stage", true);
1012 assert!(r.body.is_none());
1013 assert_eq!(r.body_format, BodyFormat::Plain);
1014 assert!(r.tool_name.is_none());
1015 assert!(r.tool_arguments.is_none());
1016 assert!(r.options.is_empty());
1017 }
1018
1019 #[test]
1020 fn test_multiple_choice_request_is_required() {
1021 let r = InteractionRequest::multiple_choice("mc", "Pick", vec!["A".into()], "stage");
1022 assert!(r.required);
1023 }
1024
1025 #[test]
1026 fn test_confirm_request_is_required() {
1027 let r = InteractionRequest::confirm("c", "Sure?", "stage");
1028 assert!(r.required);
1029 }
1030
1031 #[test]
1032 fn test_tool_approval_is_required() {
1033 let r =
1034 InteractionRequest::tool_approval("ta", "bash", serde_json::json!({}), "stage", &[]);
1035 assert!(r.required);
1036 }
1037
1038 #[test]
1039 fn test_interaction_kind_snake_case_values() {
1040 assert_eq!(
1041 serde_json::to_string(&InteractionKind::FreeText).unwrap(),
1042 "\"free_text\""
1043 );
1044 assert_eq!(
1045 serde_json::to_string(&InteractionKind::MultipleChoice).unwrap(),
1046 "\"multiple_choice\""
1047 );
1048 assert_eq!(
1049 serde_json::to_string(&InteractionKind::ToolApproval).unwrap(),
1050 "\"tool_approval\""
1051 );
1052 assert_eq!(
1053 serde_json::to_string(&InteractionKind::Confirm).unwrap(),
1054 "\"confirm\""
1055 );
1056 }
1057
1058 #[test]
1059 fn test_body_format_snake_case_values() {
1060 assert_eq!(
1061 serde_json::to_string(&BodyFormat::Plain).unwrap(),
1062 "\"plain\""
1063 );
1064 assert_eq!(
1065 serde_json::to_string(&BodyFormat::Markdown).unwrap(),
1066 "\"markdown\""
1067 );
1068 }
1069
1070 #[test]
1071 fn test_request_free_text_serde_roundtrip() {
1072 let req = InteractionRequest::free_text("ft1", "What?", "main", false);
1073 let json = serde_json::to_string(&req).unwrap();
1074 let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1075 assert_eq!(back.id, "ft1");
1076 assert_eq!(back.kind, InteractionKind::FreeText);
1077 assert!(!back.required);
1078 assert_eq!(back.stage_name, "main");
1079 }
1080
1081 #[test]
1082 fn test_request_multiple_choice_serde_roundtrip() {
1083 let req = InteractionRequest::multiple_choice(
1084 "mc1",
1085 "Choose",
1086 vec!["A".into(), "B".into(), "C".into()],
1087 "plan",
1088 );
1089 let json = serde_json::to_string(&req).unwrap();
1090 let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1091 assert_eq!(back.kind, InteractionKind::MultipleChoice);
1092 assert_eq!(back.options.len(), 3);
1093 assert_eq!(back.options[2], "C");
1094 }
1095
1096 #[test]
1097 fn test_request_confirm_serde_roundtrip() {
1098 let req = InteractionRequest::confirm("c1", "Proceed?", "deploy");
1099 let json = serde_json::to_string(&req).unwrap();
1100 let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1101 assert_eq!(back.kind, InteractionKind::Confirm);
1102 assert_eq!(back.options, vec!["Yes", "No"]);
1103 }
1104
1105 #[test]
1106 fn test_request_review_serde_roundtrip() {
1107 let req = InteractionRequest::review("rev1", "Title", "# Body\ntext", "review");
1108 let json = serde_json::to_string(&req).unwrap();
1109 let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1110 assert_eq!(back.body_format, BodyFormat::Markdown);
1111 assert_eq!(back.body.as_deref(), Some("# Body\ntext"));
1112 }
1113
1114 #[test]
1115 fn test_response_text_serde_roundtrip() {
1116 let resp = InteractionResponse::text("t1", "my answer");
1117 let json = serde_json::to_string(&resp).unwrap();
1118 let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1119 assert_eq!(back.request_id, "t1");
1120 assert_eq!(back.value.as_deref(), Some("my answer"));
1121 assert!(back.choice_index.is_none());
1122 assert!(back.approved.is_none());
1123 assert!(back.scope.is_none());
1124 }
1125
1126 #[test]
1127 fn test_response_choice_serde_roundtrip() {
1128 let resp = InteractionResponse::choice("c1", 2);
1129 let json = serde_json::to_string(&resp).unwrap();
1130 let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1131 assert_eq!(back.choice_index, Some(2));
1132 assert!(back.value.is_none());
1133 }
1134
1135 #[test]
1136 fn test_response_approval_serde_roundtrip() {
1137 let resp = InteractionResponse::approval("a1", false, ApprovalScope::Run);
1138 let json = serde_json::to_string(&resp).unwrap();
1139 let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1140 assert_eq!(back.approved, Some(false));
1141 assert_eq!(back.scope, Some(ApprovalScope::Run));
1142 }
1143
1144 #[test]
1145 fn test_response_as_text_with_value() {
1146 let r = InteractionResponse::text("id", "some text value");
1147 assert_eq!(response_as_text(&r), "some text value");
1148 }
1149
1150 #[test]
1151 fn test_response_approved_session_scope() {
1152 let r = InteractionResponse::approval("id", true, ApprovalScope::Run);
1153 assert!(response_approved(&r));
1154 assert_eq!(r.scope, Some(ApprovalScope::Run));
1155 }
1156
1157 #[test]
1158 fn test_make_interaction_id_various() {
1159 assert_eq!(make_interaction_id(1, 2), "1-2");
1160 assert_eq!(make_interaction_id(10, 20), "10-20");
1161 }
1162
1163 #[test]
1164 fn test_default_true_via_serde_missing_required_field() {
1165 let json = r#"{
1167 "id": "dt1",
1168 "kind": "free_text",
1169 "prompt": "test prompt",
1170 "stage_name": "stage"
1171 }"#;
1172 let req: InteractionRequest = serde_json::from_str(json).unwrap();
1173 assert!(req.required);
1174 }
1175 #[test]
1180 fn feedback_is_absent_from_the_wire_unless_given() {
1181 let plain = InteractionResponse::approval("q1", false, ApprovalScope::Once);
1182 let json = serde_json::to_value(&plain).unwrap();
1183 assert!(json.get("feedback").is_none(), "{json}");
1184 let old_wire: InteractionResponse =
1185 serde_json::from_str(r#"{"request_id":"q1","value":null,"choice_index":null,"approved":false,"scope":"once"}"#)
1186 .unwrap();
1187 assert_eq!(old_wire, plain);
1188
1189 let with = InteractionResponse::deny_with_feedback("q1", " use git log instead \n");
1190 let json = serde_json::to_value(&with).unwrap();
1191 assert_eq!(json["feedback"], "use git log instead");
1192 assert_eq!(json["approved"], false);
1193 let back: InteractionResponse = serde_json::from_value(json).unwrap();
1194 assert_eq!(back, with);
1195 assert_eq!(back.deny_feedback(), Some("use git log instead"));
1196 }
1197
1198 #[test]
1201 fn blank_feedback_is_the_plain_deny() {
1202 let blank = InteractionResponse::deny_with_feedback("q1", " \n\t");
1203 assert_eq!(blank.feedback, None);
1204 assert_eq!(blank.approved, Some(false));
1205 assert_eq!(blank.deny_feedback(), None);
1206 let padded = InteractionResponse {
1207 feedback: Some(" \n ".to_string()),
1208 ..InteractionResponse::approval("q1", false, ApprovalScope::Once)
1209 };
1210 assert_eq!(padded.deny_feedback(), None);
1211 }
1212
1213 #[test]
1215 fn feedback_is_only_read_on_a_deny() {
1216 let granted = InteractionResponse {
1217 feedback: Some("why".to_string()),
1218 ..InteractionResponse::approval("q1", true, ApprovalScope::Run)
1219 };
1220 assert_eq!(granted.deny_feedback(), None);
1221 let undecided = InteractionResponse {
1222 feedback: Some("why".to_string()),
1223 ..InteractionResponse::text("q1", "")
1224 };
1225 assert_eq!(undecided.deny_feedback(), None);
1226 }
1227
1228 #[test]
1231 fn the_feedback_row_is_found_by_label_on_tool_approvals_only() {
1232 let tool =
1233 InteractionRequest::tool_approval("id", "shell", serde_json::json!({}), "s", &[]);
1234 assert!(tool.is_deny_with_feedback(4));
1235 assert!(!tool.is_deny_with_feedback(3));
1236 assert!(!tool.is_deny_with_feedback(99));
1237 let gate = InteractionRequest::gate_approval("id", "web_fetch", serde_json::json!({}), "s");
1238 assert!(!gate.is_deny_with_feedback(2));
1239 assert!(!gate.is_deny_with_feedback(4));
1240 let choice = InteractionRequest::multiple_choice(
1241 "id",
1242 "?",
1243 vec![DENY_WITH_FEEDBACK.to_string()],
1244 "s",
1245 );
1246 assert!(
1247 !choice.is_deny_with_feedback(0),
1248 "the label alone is not the row"
1249 );
1250 }
1251}