Skip to main content

leviath_runtime/
dynamic_interaction.rs

1//! Agent-initiated dynamic interaction tools: `present_for_review`,
2//! `ask_user_text`, `ask_user_choice`, `ask_user_confirm`, `edit_document`.
3//!
4//! Unlike `interaction_points` (declared statically in a blueprint and
5//! always fired), these are ordinary tool calls the model makes on its own
6//! judgment, mid-reasoning. Both the background worker (file-based IPC) and
7//! the foreground (stdin) run modes need to intercept these tool names
8//! before they ever reach the generic tool registry - this module holds
9//! that shared logic behind an [`InteractionBackend`] trait so it can be
10//! unit tested with a mock instead of only living inside untestable
11//! closures.
12
13use 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
18// ─── Shared taint-gate prompt helpers ──────────────────────────────────────
19// Used by both the worker (IPC) and foreground (stdin) GatePrompt impls so the
20// decision-parsing / arg-building / approval-mapping logic is written and
21// tested once, not duplicated across two untestable I/O closures.
22
23/// Extract `(tool_name, taint, clearance)` from a blocked gate decision, or
24/// `None` if the decision isn't a block.
25pub 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
39/// Build the approval-prompt arguments explaining why an outbound call was gated.
40pub 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
54/// Map an approval outcome (approved, session-scope) to a gate resolution.
55pub 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
64/// Resolve a foreground taint-gate block by asking via `ask` (real stdin in
65/// production, a mock in tests) and mapping the response. Kept free of the
66/// blocking stdin call itself so the request-building + mapping are testable.
67pub 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/// How a dynamically-requested interaction is dispatched and logged.
90///
91/// The background worker answers via the file-based IPC channel and logs to
92/// the per-stage log file; the foreground path answers via stdin and prints
93/// directly. Both share the exact same tool-argument parsing and response
94/// formatting in [`dispatch_dynamic_interaction`].
95#[async_trait]
96pub trait InteractionBackend: Send + Sync {
97    /// Block until the user answers `req`.
98    async fn ask(&self, req: InteractionRequest) -> InteractionResponse;
99
100    /// Record an operational log line. No-op by default (the foreground
101    /// path has no per-stage log file to write to).
102    fn log(&self, message: &str) {
103        let _ = message;
104    }
105
106    /// Called only for `present_for_review`, once, before asking: persist
107    /// or display the document. No-op by default.
108    fn on_review_document(&self, tool_call_id: &str, title: &str, markdown: &str) {
109        let _ = (tool_call_id, title, markdown);
110    }
111}
112
113/// What an unattended run tells the model when a question needed a person.
114pub const UNATTENDED_NO_ANSWER: &str =
115    "[unattended run] No user was available to answer (--yolo). Decide for yourself and continue.";
116
117/// The answer an unattended run (`--yolo`) gives a request nobody is there to
118/// see.
119///
120/// `--yolo` means "run without a human", so a prompt that blocks on one would
121/// park the run forever - a headless run would hang at the first
122/// `ask_user_confirm`.
123///
124/// A confirmation is approved: that is exactly what the flag promises. A
125/// *choice* is deliberately **not** made - picking option 0 unseen could select
126/// "Abort" or a destructive branch - so the model is told no one answered and
127/// left to decide. An edit submits the document unchanged, and a document put up
128/// for review is acknowledged without comment (a review is a `FreeText` request
129/// carrying a `body`; a question is one without).
130pub 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
146/// An [`InteractionBackend`] for unattended runs: answers every request from
147/// [`unattended_answer`] instead of opening a prompt on the hub.
148pub 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
157/// Dispatch a single dynamic-interaction tool call.
158///
159/// Returns `Some(result_string)` if `tool_name` is one of
160/// `present_for_review` / `ask_user_text` / `ask_user_choice` /
161/// `ask_user_confirm` (and was therefore handled here); returns `None` for
162/// any other tool name so the caller can fall through to normal tool dispatch.
163pub 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    /// Records every `ask()` request and `log()`/`on_review_document()` call,
361    /// and returns a pre-scripted response for each `ask()` in order.
362    #[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    // ─── dispatch_dynamic_interaction: routing ─────────────────────────────
405
406    #[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    // ─── present_for_review ─────────────────────────────────────────────────
445
446    #[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    // ─── ask_user_text ──────────────────────────────────────────────────────
539
540    #[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    // ─── ask_user_choice ────────────────────────────────────────────────────
608
609    #[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        // Must not have asked the user anything for an invalid call.
657        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    // ─── ask_user_confirm ───────────────────────────────────────────────────
699
700    #[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        // response_approved() defaults false for a response with no `approved` set.
741        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    // ─── edit_document ──────────────────────────────────────────────────────
779
780    #[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    // ─── log() / on_review_document() default no-ops don't panic ──────────
820
821    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    // ─── taint-gate prompt helpers ──────────────────────────────────────────
855
856    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        // Allowed decisions yield None.
872        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        // Deny.
899        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        // Always-allow (session scope). Also assert the request the asker saw is
904        // a taint-gate tool-approval for the right tool.
905        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        // A text response (no approval) denies. Bind the asker as a fn pointer
912        // (Copy) so its body is exercised here, then reuse it below where the
913        // short-circuit means it is never invoked.
914        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        // A non-block decision short-circuits to AllowOnce without asking - the
919        // (already-covered) asker is never invoked.
920        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    // ── unattended (--yolo) answers ───────────────────────────────────────
929
930    #[tokio::test]
931    async fn unattended_answers_every_prompt_without_a_hub() {
932        // Issue #107: `--yolo` means "run without a human", so a prompt that
933        // waits for one parks the run forever. Every dynamic-interaction tool
934        // must come back with something the model can act on.
935        let backend = UnattendedInteraction;
936
937        // A confirmation is approved - that is what the flag promises.
938        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        // A *choice* is deliberately left unmade: picking an option unseen could
950        // select "Abort" or a destructive branch, so the model is told nobody
951        // answered and decides for itself.
952        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        // Free text says so plainly.
965        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        // A review is acknowledged, and an edit submits the document unchanged.
977        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        // The tool-policy layer normally short-circuits these under --yolo, so
1003        // cover the arm directly.
1004        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}