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