1use async_trait::async_trait;
14
15use leviath_core::interaction::{ApprovalScope, InteractionRequest, InteractionResponse};
16use leviath_core::interaction::{response_approved, response_as_choice, response_as_text};
17
18pub fn gate_block_info(
26 decision: &leviath_core::taint::GateDecision,
27) -> Option<(String, leviath_core::TaintLevel, leviath_core::TaintLevel)> {
28 match decision {
29 leviath_core::taint::GateDecision::Blocked {
30 tool_name,
31 taint_level,
32 clearance,
33 ..
34 } => Some((tool_name.clone(), *taint_level, *clearance)),
35 _ => None,
36 }
37}
38
39pub fn gate_prompt_args(
41 tool_name: &str,
42 taint: leviath_core::TaintLevel,
43 clearance: leviath_core::TaintLevel,
44) -> serde_json::Value {
45 serde_json::json!({
46 "taint_gate": true,
47 "reason": format!(
48 "Outbound tool '{}' would carry {}-sensitivity data above its {} clearance.",
49 tool_name, taint, clearance
50 ),
51 })
52}
53
54pub fn map_gate_approval(approved: bool, session: bool) -> crate::taint::GateResolution {
56 use crate::taint::GateResolution;
57 match (approved, session) {
58 (false, _) => GateResolution::Deny,
59 (true, true) => GateResolution::AlwaysAllow,
60 (true, false) => GateResolution::AllowOnce,
61 }
62}
63
64pub fn resolve_gate_with_asker(
68 decision: &leviath_core::taint::GateDecision,
69 stage_name: &str,
70 ask: impl Fn(&InteractionRequest) -> InteractionResponse,
71) -> crate::taint::GateResolution {
72 use crate::taint::GateResolution;
73 let Some((tool_name, taint, clearance)) = gate_block_info(decision) else {
74 return GateResolution::AllowOnce;
75 };
76 let req = InteractionRequest::gate_approval(
77 format!("taint-{}", tool_name),
78 &tool_name,
79 gate_prompt_args(&tool_name, taint, clearance),
80 stage_name,
81 );
82 let resp = ask(&req);
83 map_gate_approval(
87 response_approved(&resp),
88 resp.scope.is_some_and(|s| s != ApprovalScope::Once),
89 )
90}
91
92#[async_trait]
99pub trait InteractionBackend: Send + Sync {
100 async fn ask(&self, req: InteractionRequest) -> InteractionResponse;
102
103 fn log(&self, message: &str) {
106 let _ = message;
107 }
108
109 fn on_review_document(&self, tool_call_id: &str, title: &str, markdown: &str) {
112 let _ = (tool_call_id, title, markdown);
113 }
114}
115
116pub const UNATTENDED_NO_ANSWER: &str =
118 "[unattended run] No user was available to answer (--yolo). Decide for yourself and continue.";
119
120pub fn unattended_answer(req: &InteractionRequest) -> InteractionResponse {
134 use leviath_core::interaction::InteractionKind;
135 match req.kind {
136 InteractionKind::Confirm | InteractionKind::ToolApproval => {
137 InteractionResponse::approval(&req.id, true, ApprovalScope::Once)
138 }
139 InteractionKind::EditText => {
140 InteractionResponse::text(&req.id, req.body.clone().unwrap_or_default())
141 }
142 InteractionKind::FreeText if req.body.is_some() => InteractionResponse::text(&req.id, ""),
143 InteractionKind::FreeText | InteractionKind::MultipleChoice => {
144 InteractionResponse::text(&req.id, UNATTENDED_NO_ANSWER)
145 }
146 }
147}
148
149pub struct UnattendedInteraction;
152
153#[async_trait]
154impl InteractionBackend for UnattendedInteraction {
155 async fn ask(&self, req: InteractionRequest) -> InteractionResponse {
156 unattended_answer(&req)
157 }
158}
159
160pub const BLOCKING_INTERACTION_TOOLS: &[&str] = &[
170 "present_for_review",
171 "ask_user_text",
172 "ask_user_choice",
173 "ask_user_confirm",
174 "edit_document",
175];
176
177pub async fn dispatch_dynamic_interaction(
184 backend: &dyn InteractionBackend,
185 tool_name: &str,
186 tool_call_id: &str,
187 arguments: &serde_json::Value,
188 stage_name: &str,
189) -> Option<String> {
190 match tool_name {
191 "present_for_review" => {
192 Some(handle_present_for_review(backend, tool_call_id, arguments, stage_name).await)
193 }
194 "ask_user_text" => {
195 Some(handle_ask_user_text(backend, tool_call_id, arguments, stage_name).await)
196 }
197 "ask_user_choice" => {
198 Some(handle_ask_user_choice(backend, tool_call_id, arguments, stage_name).await)
199 }
200 "ask_user_confirm" => {
201 Some(handle_ask_user_confirm(backend, tool_call_id, arguments, stage_name).await)
202 }
203 "edit_document" => {
204 Some(handle_edit_document(backend, tool_call_id, arguments, stage_name).await)
205 }
206 _ => None,
207 }
208}
209
210fn arg_str<'a>(arguments: &'a serde_json::Value, key: &str, default: &'a str) -> String {
211 arguments
212 .get(key)
213 .and_then(|v| v.as_str())
214 .unwrap_or(default)
215 .to_string()
216}
217
218async fn handle_present_for_review(
219 backend: &dyn InteractionBackend,
220 tool_call_id: &str,
221 arguments: &serde_json::Value,
222 stage_name: &str,
223) -> String {
224 let title = arg_str(arguments, "title", "Review");
225 let markdown = arg_str(arguments, "markdown", "");
226
227 backend.on_review_document(tool_call_id, &title, &markdown);
228 backend.log(&format!(
229 "[tool] present_for_review \u{2192} waiting for user review: {}",
230 title
231 ));
232
233 let req = InteractionRequest::review(
234 format!("review-{}", tool_call_id),
235 &title,
236 &markdown,
237 stage_name,
238 );
239 let resp = backend.ask(req).await;
240 let user_feedback = response_as_text(&resp);
241
242 backend.log("[tool] present_for_review \u{2192} done");
243
244 if user_feedback.trim().is_empty() {
245 "User reviewed the document and acknowledged.".to_string()
246 } else {
247 format!("User feedback: {}", user_feedback)
248 }
249}
250
251async fn handle_ask_user_text(
252 backend: &dyn InteractionBackend,
253 tool_call_id: &str,
254 arguments: &serde_json::Value,
255 stage_name: &str,
256) -> String {
257 let prompt = arg_str(arguments, "prompt", "");
258
259 backend.log(&format!(
260 "[tool] ask_user_text \u{2192} waiting: {}",
261 prompt
262 ));
263
264 let req =
265 InteractionRequest::free_text(format!("ask-{}", tool_call_id), &prompt, stage_name, true);
266 let resp = backend.ask(req).await;
267 let answer = response_as_text(&resp);
268
269 backend.log("[tool] ask_user_text \u{2192} done");
270
271 if answer.trim().is_empty() {
272 "User provided no answer.".to_string()
273 } else {
274 answer
275 }
276}
277
278async fn handle_ask_user_choice(
279 backend: &dyn InteractionBackend,
280 tool_call_id: &str,
281 arguments: &serde_json::Value,
282 stage_name: &str,
283) -> String {
284 let prompt = arg_str(arguments, "prompt", "");
285 let options: Vec<String> = arguments
286 .get("options")
287 .and_then(|v| v.as_array())
288 .map(|arr| {
289 arr.iter()
290 .filter_map(|v| v.as_str().map(|s| s.to_string()))
291 .collect()
292 })
293 .unwrap_or_default();
294
295 if options.len() < 2 {
296 return "[error] ask_user_choice requires at least 2 options".to_string();
297 }
298
299 backend.log(&format!(
300 "[tool] ask_user_choice \u{2192} waiting: {}",
301 prompt
302 ));
303
304 let req = InteractionRequest::multiple_choice(
305 format!("ask-{}", tool_call_id),
306 &prompt,
307 options.clone(),
308 stage_name,
309 );
310 let resp = backend.ask(req).await;
311 let choice = response_as_choice(&resp, &options)
312 .cloned()
313 .unwrap_or_else(|| response_as_text(&resp));
314
315 backend.log("[tool] ask_user_choice \u{2192} done");
316
317 format!("User chose: {}", choice)
318}
319
320async fn handle_ask_user_confirm(
321 backend: &dyn InteractionBackend,
322 tool_call_id: &str,
323 arguments: &serde_json::Value,
324 stage_name: &str,
325) -> String {
326 let prompt = arg_str(arguments, "prompt", "");
327
328 backend.log(&format!(
329 "[tool] ask_user_confirm \u{2192} waiting: {}",
330 prompt
331 ));
332
333 let req = InteractionRequest::confirm(format!("ask-{}", tool_call_id), &prompt, stage_name);
334 let resp = backend.ask(req).await;
335 let approved = response_approved(&resp);
336
337 backend.log("[tool] ask_user_confirm \u{2192} done");
338
339 format!("User answered: {}", if approved { "Yes" } else { "No" })
340}
341
342async fn handle_edit_document(
343 backend: &dyn InteractionBackend,
344 tool_call_id: &str,
345 arguments: &serde_json::Value,
346 stage_name: &str,
347) -> String {
348 let content = arg_str(arguments, "content", "");
349 let prompt = arg_str(
350 arguments,
351 "prompt",
352 "Edit the document below, then submit your changes:",
353 );
354
355 backend.log("[tool] edit_document \u{2192} waiting for user edits");
356
357 let req = InteractionRequest::edit_text(
358 format!("edit-{}", tool_call_id),
359 &prompt,
360 stage_name,
361 &content,
362 );
363 let resp = backend.ask(req).await;
364 let edited = response_as_text(&resp);
365
366 backend.log("[tool] edit_document \u{2192} done");
367
368 if edited.trim().is_empty() {
369 format!("User made no changes. Current document:\n{}", content)
370 } else {
371 format!("User-edited document:\n{}", edited)
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use std::sync::Mutex;
379
380 #[derive(Default)]
383 struct MockBackend {
384 responses: Mutex<Vec<InteractionResponse>>,
385 asked: Mutex<Vec<InteractionRequest>>,
386 logs: Mutex<Vec<String>>,
387 reviews: Mutex<Vec<(String, String, String)>>,
388 }
389
390 impl MockBackend {
391 fn with_responses(responses: Vec<InteractionResponse>) -> Self {
392 Self {
393 responses: Mutex::new(responses),
394 ..Default::default()
395 }
396 }
397 }
398
399 #[async_trait]
400 impl InteractionBackend for MockBackend {
401 async fn ask(&self, req: InteractionRequest) -> InteractionResponse {
402 self.asked.lock().unwrap().push(req);
403 let mut responses = self.responses.lock().unwrap();
404 if responses.is_empty() {
405 InteractionResponse::text("", "")
406 } else {
407 responses.remove(0)
408 }
409 }
410
411 fn log(&self, message: &str) {
412 self.logs.lock().unwrap().push(message.to_string());
413 }
414
415 fn on_review_document(&self, tool_call_id: &str, title: &str, markdown: &str) {
416 self.reviews.lock().unwrap().push((
417 tool_call_id.to_string(),
418 title.to_string(),
419 markdown.to_string(),
420 ));
421 }
422 }
423
424 #[tokio::test]
427 async fn dispatch_unknown_tool_returns_none() {
428 let backend = MockBackend::default();
429 let result = dispatch_dynamic_interaction(
430 &backend,
431 "read_file",
432 "id1",
433 &serde_json::json!({}),
434 "main",
435 )
436 .await;
437 assert!(result.is_none());
438 assert!(backend.asked.lock().unwrap().is_empty());
439 }
440
441 #[tokio::test]
442 async fn all_dynamic_interaction_tool_names_are_handled() {
443 for name in BLOCKING_INTERACTION_TOOLS.iter().copied() {
444 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "ok")]);
445 let result = dispatch_dynamic_interaction(
446 &backend,
447 name,
448 "id1",
449 &serde_json::json!({"title": "t", "markdown": "m", "prompt": "p", "options": ["A", "B"]}),
450 "main",
451 )
452 .await;
453 assert!(result.is_some());
454 }
455 }
456
457 #[tokio::test]
460 async fn present_for_review_persists_document_before_asking() {
461 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
462 let result = dispatch_dynamic_interaction(
463 &backend,
464 "present_for_review",
465 "call1",
466 &serde_json::json!({"title": "My Plan", "markdown": "# Plan\ndetails"}),
467 "plan",
468 )
469 .await
470 .unwrap();
471
472 assert_eq!(result, "User reviewed the document and acknowledged.");
473 let reviews = backend.reviews.lock().unwrap();
474 assert_eq!(reviews.len(), 1);
475 assert_eq!(reviews[0].0, "call1");
476 assert_eq!(reviews[0].1, "My Plan");
477 assert_eq!(reviews[0].2, "# Plan\ndetails");
478 }
479
480 #[tokio::test]
481 async fn present_for_review_returns_feedback_when_given() {
482 let backend =
483 MockBackend::with_responses(vec![InteractionResponse::text("", "looks great")]);
484 let result = dispatch_dynamic_interaction(
485 &backend,
486 "present_for_review",
487 "call2",
488 &serde_json::json!({"title": "Design", "markdown": "body"}),
489 "plan",
490 )
491 .await
492 .unwrap();
493 assert_eq!(result, "User feedback: looks great");
494 }
495
496 #[tokio::test]
497 async fn present_for_review_defaults_missing_title_and_markdown() {
498 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
499 dispatch_dynamic_interaction(
500 &backend,
501 "present_for_review",
502 "call3",
503 &serde_json::json!({}),
504 "plan",
505 )
506 .await;
507 let reviews = backend.reviews.lock().unwrap();
508 assert_eq!(reviews[0].1, "Review");
509 assert_eq!(reviews[0].2, "");
510 }
511
512 #[tokio::test]
513 async fn present_for_review_builds_review_kind_request() {
514 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
515 dispatch_dynamic_interaction(
516 &backend,
517 "present_for_review",
518 "call4",
519 &serde_json::json!({"title": "T", "markdown": "M"}),
520 "plan",
521 )
522 .await;
523 let asked = backend.asked.lock().unwrap();
524 assert_eq!(asked.len(), 1);
525 assert_eq!(asked[0].id, "review-call4");
526 assert_eq!(asked[0].prompt, "T");
527 assert_eq!(asked[0].body.as_deref(), Some("M"));
528 assert_eq!(
529 asked[0].body_format,
530 leviath_core::interaction::BodyFormat::Markdown
531 );
532 assert_eq!(asked[0].stage_name, "plan");
533 }
534
535 #[tokio::test]
536 async fn present_for_review_logs_waiting_and_done() {
537 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
538 dispatch_dynamic_interaction(
539 &backend,
540 "present_for_review",
541 "call5",
542 &serde_json::json!({"title": "T", "markdown": "M"}),
543 "plan",
544 )
545 .await;
546 let logs = backend.logs.lock().unwrap();
547 assert!(logs[0].contains("waiting for user review: T"));
548 assert!(logs[1].contains("done"));
549 }
550
551 #[tokio::test]
554 async fn ask_user_text_returns_answer() {
555 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "blue")]);
556 let result = dispatch_dynamic_interaction(
557 &backend,
558 "ask_user_text",
559 "call1",
560 &serde_json::json!({"prompt": "What color?"}),
561 "plan",
562 )
563 .await
564 .unwrap();
565 assert_eq!(result, "blue");
566 }
567
568 #[tokio::test]
569 async fn ask_user_text_empty_answer_reports_no_answer() {
570 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", " ")]);
571 let result = dispatch_dynamic_interaction(
572 &backend,
573 "ask_user_text",
574 "call2",
575 &serde_json::json!({"prompt": "Anything?"}),
576 "plan",
577 )
578 .await
579 .unwrap();
580 assert_eq!(result, "User provided no answer.");
581 }
582
583 #[tokio::test]
584 async fn ask_user_text_builds_free_text_required_request() {
585 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "x")]);
586 dispatch_dynamic_interaction(
587 &backend,
588 "ask_user_text",
589 "call3",
590 &serde_json::json!({"prompt": "Q?"}),
591 "implement",
592 )
593 .await;
594 let asked = backend.asked.lock().unwrap();
595 assert_eq!(asked[0].id, "ask-call3");
596 assert_eq!(asked[0].prompt, "Q?");
597 assert!(asked[0].required);
598 assert_eq!(
599 asked[0].kind,
600 leviath_core::interaction::InteractionKind::FreeText
601 );
602 assert_eq!(asked[0].stage_name, "implement");
603 }
604
605 #[tokio::test]
606 async fn ask_user_text_missing_prompt_defaults_empty() {
607 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "x")]);
608 dispatch_dynamic_interaction(
609 &backend,
610 "ask_user_text",
611 "call4",
612 &serde_json::json!({}),
613 "plan",
614 )
615 .await;
616 let asked = backend.asked.lock().unwrap();
617 assert_eq!(asked[0].prompt, "");
618 }
619
620 #[tokio::test]
623 async fn ask_user_choice_returns_chosen_option() {
624 let backend = MockBackend::with_responses(vec![InteractionResponse::choice("", 1)]);
625 let result = dispatch_dynamic_interaction(
626 &backend,
627 "ask_user_choice",
628 "call1",
629 &serde_json::json!({"prompt": "Pick one", "options": ["A", "B", "C"]}),
630 "plan",
631 )
632 .await
633 .unwrap();
634 assert_eq!(result, "User chose: B");
635 }
636
637 #[tokio::test]
638 async fn ask_user_choice_falls_back_to_text_when_no_choice_index() {
639 let backend =
640 MockBackend::with_responses(vec![InteractionResponse::text("", "custom answer")]);
641 let result = dispatch_dynamic_interaction(
642 &backend,
643 "ask_user_choice",
644 "call2",
645 &serde_json::json!({"prompt": "Pick one", "options": ["A", "B"]}),
646 "plan",
647 )
648 .await
649 .unwrap();
650 assert_eq!(result, "User chose: custom answer");
651 }
652
653 #[tokio::test]
654 async fn ask_user_choice_rejects_fewer_than_two_options() {
655 let backend = MockBackend::default();
656 let result = dispatch_dynamic_interaction(
657 &backend,
658 "ask_user_choice",
659 "call3",
660 &serde_json::json!({"prompt": "Pick one", "options": ["A"]}),
661 "plan",
662 )
663 .await
664 .unwrap();
665 assert_eq!(
666 result,
667 "[error] ask_user_choice requires at least 2 options"
668 );
669 assert!(backend.asked.lock().unwrap().is_empty());
671 }
672
673 #[tokio::test]
674 async fn ask_user_choice_rejects_missing_options() {
675 let backend = MockBackend::default();
676 let result = dispatch_dynamic_interaction(
677 &backend,
678 "ask_user_choice",
679 "call4",
680 &serde_json::json!({"prompt": "Pick one"}),
681 "plan",
682 )
683 .await
684 .unwrap();
685 assert_eq!(
686 result,
687 "[error] ask_user_choice requires at least 2 options"
688 );
689 }
690
691 #[tokio::test]
692 async fn ask_user_choice_builds_multiple_choice_request() {
693 let backend = MockBackend::with_responses(vec![InteractionResponse::choice("", 0)]);
694 dispatch_dynamic_interaction(
695 &backend,
696 "ask_user_choice",
697 "call5",
698 &serde_json::json!({"prompt": "Q?", "options": ["X", "Y"]}),
699 "plan",
700 )
701 .await;
702 let asked = backend.asked.lock().unwrap();
703 assert_eq!(asked[0].id, "ask-call5");
704 assert_eq!(
705 asked[0].kind,
706 leviath_core::interaction::InteractionKind::MultipleChoice
707 );
708 assert_eq!(asked[0].options, vec!["X".to_string(), "Y".to_string()]);
709 }
710
711 #[tokio::test]
714 async fn ask_user_confirm_yes() {
715 let backend = MockBackend::with_responses(vec![InteractionResponse::approval(
716 "",
717 true,
718 leviath_core::interaction::ApprovalScope::Once,
719 )]);
720 let result = dispatch_dynamic_interaction(
721 &backend,
722 "ask_user_confirm",
723 "call1",
724 &serde_json::json!({"prompt": "Proceed?"}),
725 "implement",
726 )
727 .await
728 .unwrap();
729 assert_eq!(result, "User answered: Yes");
730 }
731
732 #[tokio::test]
733 async fn ask_user_confirm_no() {
734 let backend = MockBackend::with_responses(vec![InteractionResponse::approval(
735 "",
736 false,
737 leviath_core::interaction::ApprovalScope::Once,
738 )]);
739 let result = dispatch_dynamic_interaction(
740 &backend,
741 "ask_user_confirm",
742 "call2",
743 &serde_json::json!({"prompt": "Proceed?"}),
744 "implement",
745 )
746 .await
747 .unwrap();
748 assert_eq!(result, "User answered: No");
749 }
750
751 #[tokio::test]
752 async fn ask_user_confirm_defaults_to_no_when_unanswered() {
753 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
755 let result = dispatch_dynamic_interaction(
756 &backend,
757 "ask_user_confirm",
758 "call3",
759 &serde_json::json!({"prompt": "Proceed?"}),
760 "implement",
761 )
762 .await
763 .unwrap();
764 assert_eq!(result, "User answered: No");
765 }
766
767 #[tokio::test]
768 async fn ask_user_confirm_builds_confirm_request() {
769 let backend = MockBackend::with_responses(vec![InteractionResponse::approval(
770 "",
771 true,
772 leviath_core::interaction::ApprovalScope::Once,
773 )]);
774 dispatch_dynamic_interaction(
775 &backend,
776 "ask_user_confirm",
777 "call4",
778 &serde_json::json!({"prompt": "Sure?"}),
779 "implement",
780 )
781 .await;
782 let asked = backend.asked.lock().unwrap();
783 assert_eq!(asked[0].id, "ask-call4");
784 assert_eq!(
785 asked[0].kind,
786 leviath_core::interaction::InteractionKind::Confirm
787 );
788 assert_eq!(asked[0].options, vec!["Yes".to_string(), "No".to_string()]);
789 }
790
791 #[tokio::test]
794 async fn edit_document_returns_edited_text() {
795 let backend =
796 MockBackend::with_responses(vec![InteractionResponse::text("", "edited plan")]);
797 let result = dispatch_dynamic_interaction(
798 &backend,
799 "edit_document",
800 "call1",
801 &serde_json::json!({"content": "original plan"}),
802 "plan",
803 )
804 .await
805 .unwrap();
806
807 assert_eq!(result, "User-edited document:\nedited plan");
808 let asked = backend.asked.lock().unwrap();
809 assert_eq!(asked[0].id, "edit-call1");
810 assert_eq!(
811 asked[0].kind,
812 leviath_core::interaction::InteractionKind::EditText
813 );
814 assert_eq!(asked[0].body.as_deref(), Some("original plan"));
815 }
816
817 #[tokio::test]
818 async fn edit_document_empty_edit_returns_original_content() {
819 let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
820 let result = dispatch_dynamic_interaction(
821 &backend,
822 "edit_document",
823 "call2",
824 &serde_json::json!({"content": "keep this"}),
825 "plan",
826 )
827 .await
828 .unwrap();
829 assert_eq!(result, "User made no changes. Current document:\nkeep this");
830 }
831
832 struct NoopBackend;
835
836 #[async_trait]
837 impl InteractionBackend for NoopBackend {
838 async fn ask(&self, _req: InteractionRequest) -> InteractionResponse {
839 InteractionResponse::text("", "answer")
840 }
841 }
842
843 #[tokio::test]
844 async fn default_log_and_review_hooks_are_noop_and_safe() {
845 let backend = NoopBackend;
846 let result = dispatch_dynamic_interaction(
847 &backend,
848 "ask_user_text",
849 "call1",
850 &serde_json::json!({"prompt": "Q?"}),
851 "plan",
852 )
853 .await;
854 assert_eq!(result, Some("answer".to_string()));
855
856 let result = dispatch_dynamic_interaction(
857 &backend,
858 "present_for_review",
859 "call2",
860 &serde_json::json!({"title": "T", "markdown": "M"}),
861 "plan",
862 )
863 .await;
864 assert_eq!(result, Some("User feedback: answer".to_string()));
865 }
866
867 fn blocked_decision(tool: &str) -> leviath_core::taint::GateDecision {
870 leviath_core::taint::GateDecision::Blocked {
871 taint_level: leviath_core::TaintLevel::Private,
872 clearance: leviath_core::TaintLevel::Public,
873 source_regions: vec!["notes".into()],
874 tool_name: tool.to_string(),
875 }
876 }
877
878 #[test]
879 fn gate_block_info_extracts_blocked_fields() {
880 let (tool, taint, clearance) = gate_block_info(&blocked_decision("shell")).unwrap();
881 assert_eq!(tool, "shell");
882 assert_eq!(taint, leviath_core::TaintLevel::Private);
883 assert_eq!(clearance, leviath_core::TaintLevel::Public);
884 assert!(gate_block_info(&leviath_core::taint::GateDecision::Allowed).is_none());
886 }
887
888 #[test]
889 fn gate_prompt_args_mentions_tool() {
890 let args = gate_prompt_args(
891 "send_email",
892 leviath_core::TaintLevel::Private,
893 leviath_core::TaintLevel::Public,
894 );
895 assert_eq!(args["taint_gate"], true);
896 assert!(args["reason"].as_str().unwrap().contains("send_email"));
897 }
898
899 #[test]
900 fn map_gate_approval_covers_all_outcomes() {
901 use crate::taint::GateResolution;
902 assert_eq!(map_gate_approval(false, false), GateResolution::Deny);
903 assert_eq!(map_gate_approval(false, true), GateResolution::Deny);
904 assert_eq!(map_gate_approval(true, false), GateResolution::AllowOnce);
905 assert_eq!(map_gate_approval(true, true), GateResolution::AlwaysAllow);
906 }
907
908 #[test]
909 fn resolve_gate_with_asker_maps_response() {
910 use crate::taint::GateResolution;
911 let r = resolve_gate_with_asker(&blocked_decision("shell"), "plan", |_req| {
913 InteractionResponse::approval("", false, ApprovalScope::Once)
914 });
915 assert_eq!(r, GateResolution::Deny);
916 let r = resolve_gate_with_asker(&blocked_decision("shell"), "plan", |req| {
919 assert_eq!(req.tool_name.as_deref(), Some("shell"));
920 assert_eq!(req.stage_name, "plan");
921 InteractionResponse::approval("", true, ApprovalScope::Run)
922 });
923 assert_eq!(r, GateResolution::AlwaysAllow);
924 let text_asker: fn(&InteractionRequest) -> InteractionResponse =
928 |_req| InteractionResponse::text("", "");
929 let denied = resolve_gate_with_asker(&blocked_decision("shell"), "plan", text_asker);
930 assert_eq!(denied, GateResolution::Deny);
931 let r = resolve_gate_with_asker(
934 &leviath_core::taint::GateDecision::Allowed,
935 "plan",
936 text_asker,
937 );
938 assert_eq!(r, GateResolution::AllowOnce);
939 }
940
941 #[tokio::test]
944 async fn unattended_answers_every_prompt_without_a_hub() {
945 let backend = UnattendedInteraction;
949
950 let confirmed = dispatch_dynamic_interaction(
952 &backend,
953 "ask_user_confirm",
954 "c1",
955 &serde_json::json!({"prompt": "Delete the branch?"}),
956 "implement",
957 )
958 .await
959 .unwrap();
960 assert_eq!(confirmed, "User answered: Yes");
961
962 let chosen = dispatch_dynamic_interaction(
966 &backend,
967 "ask_user_choice",
968 "c2",
969 &serde_json::json!({"prompt": "Which?", "options": ["Ship it", "Abort"]}),
970 "implement",
971 )
972 .await
973 .unwrap();
974 assert!(chosen.contains(UNATTENDED_NO_ANSWER), "got: {chosen}");
975 assert!(!chosen.contains("Abort"), "no option may be picked blind");
976
977 let answered = dispatch_dynamic_interaction(
979 &backend,
980 "ask_user_text",
981 "c3",
982 &serde_json::json!({"prompt": "Which database?"}),
983 "implement",
984 )
985 .await
986 .unwrap();
987 assert_eq!(answered, UNATTENDED_NO_ANSWER);
988
989 let reviewed = dispatch_dynamic_interaction(
991 &backend,
992 "present_for_review",
993 "c4",
994 &serde_json::json!({"title": "Plan", "markdown": "# Plan"}),
995 "plan",
996 )
997 .await
998 .unwrap();
999 assert!(reviewed.contains("acknowledged"), "got: {reviewed}");
1000
1001 let edited = dispatch_dynamic_interaction(
1002 &backend,
1003 "edit_document",
1004 "c5",
1005 &serde_json::json!({"content": "keep me"}),
1006 "plan",
1007 )
1008 .await
1009 .unwrap();
1010 assert!(edited.contains("keep me"), "got: {edited}");
1011 }
1012
1013 #[test]
1014 fn unattended_answer_approves_a_tool_approval() {
1015 let req =
1018 InteractionRequest::tool_approval("t1", "shell", serde_json::json!({}), "impl", &[]);
1019 let resp = unattended_answer(&req);
1020 assert!(leviath_core::interaction::response_approved(&resp));
1021 assert_eq!(resp.scope, Some(ApprovalScope::Once));
1022 }
1023}