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