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/// The tools that suspend the agent until a person answers.
158///
159/// Every name here is handled by [`dispatch_dynamic_interaction`] below, which
160/// hands the call to the interaction backend and awaits a human response - so a
161/// stage that offers one of these with nobody attached parks there for as long
162/// as the run lives. `all_dynamic_interaction_tool_names_are_handled` iterates
163/// this list, so the two cannot drift.
164///
165/// Blueprint linting reads it to flag an autonomous stage that grants one.
166pub 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
174/// Dispatch a single dynamic-interaction tool call.
175///
176/// Returns `Some(result_string)` if `tool_name` is one of
177/// `present_for_review` / `ask_user_text` / `ask_user_choice` /
178/// `ask_user_confirm` (and was therefore handled here); returns `None` for
179/// any other tool name so the caller can fall through to normal tool dispatch.
180pub 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    /// Records every `ask()` request and `log()`/`on_review_document()` call,
378    /// and returns a pre-scripted response for each `ask()` in order.
379    #[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    // ─── dispatch_dynamic_interaction: routing ─────────────────────────────
422
423    #[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    // ─── present_for_review ─────────────────────────────────────────────────
455
456    #[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    // ─── ask_user_text ──────────────────────────────────────────────────────
549
550    #[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    // ─── ask_user_choice ────────────────────────────────────────────────────
618
619    #[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        // Must not have asked the user anything for an invalid call.
667        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    // ─── ask_user_confirm ───────────────────────────────────────────────────
709
710    #[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        // response_approved() defaults false for a response with no `approved` set.
751        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    // ─── edit_document ──────────────────────────────────────────────────────
789
790    #[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    // ─── log() / on_review_document() default no-ops don't panic ──────────
830
831    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    // ─── taint-gate prompt helpers ──────────────────────────────────────────
865
866    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        // Allowed decisions yield None.
882        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        // Deny.
909        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        // Always-allow (session scope). Also assert the request the asker saw is
914        // a taint-gate tool-approval for the right tool.
915        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        // A text response (no approval) denies. Bind the asker as a fn pointer
922        // (Copy) so its body is exercised here, then reuse it below where the
923        // short-circuit means it is never invoked.
924        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        // A non-block decision short-circuits to AllowOnce without asking - the
929        // (already-covered) asker is never invoked.
930        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    // ── unattended (--yolo) answers ───────────────────────────────────────
939
940    #[tokio::test]
941    async fn unattended_answers_every_prompt_without_a_hub() {
942        // Issue #107: `--yolo` means "run without a human", so a prompt that
943        // waits for one parks the run forever. Every dynamic-interaction tool
944        // must come back with something the model can act on.
945        let backend = UnattendedInteraction;
946
947        // A confirmation is approved - that is what the flag promises.
948        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        // A *choice* is deliberately left unmade: picking an option unseen could
960        // select "Abort" or a destructive branch, so the model is told nobody
961        // answered and decides for itself.
962        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        // Free text says so plainly.
975        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        // A review is acknowledged, and an edit submits the document unchanged.
987        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        // The tool-policy layer normally short-circuits these under --yolo, so
1013        // cover the arm directly.
1014        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}