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::gate_approval(
77        format!("taint-{}", tool_name),
78        &tool_name,
79        gate_prompt_args(&tool_name, taint, clearance),
80        stage_name,
81    );
82    let resp = ask(&req);
83    // Any scope wider than `Once` raises the tool's clearance for the run. A
84    // gate has nothing narrower to offer: clearance is not keyed on what the
85    // call runs, so there is no per-stage version of it to grant.
86    map_gate_approval(
87        response_approved(&resp),
88        resp.scope.is_some_and(|s| s != ApprovalScope::Once),
89    )
90}
91
92/// How a dynamically-requested interaction is dispatched and logged.
93///
94/// The background worker answers via the file-based IPC channel and logs to
95/// the per-stage log file; the foreground path answers via stdin and prints
96/// directly. Both share the exact same tool-argument parsing and response
97/// formatting in [`dispatch_dynamic_interaction`].
98#[async_trait]
99pub trait InteractionBackend: Send + Sync {
100    /// Block until the user answers `req`.
101    async fn ask(&self, req: InteractionRequest) -> InteractionResponse;
102
103    /// Record an operational log line. No-op by default (the foreground
104    /// path has no per-stage log file to write to).
105    fn log(&self, message: &str) {
106        let _ = message;
107    }
108
109    /// Called only for `present_for_review`, once, before asking: persist
110    /// or display the document. No-op by default.
111    fn on_review_document(&self, tool_call_id: &str, title: &str, markdown: &str) {
112        let _ = (tool_call_id, title, markdown);
113    }
114}
115
116/// What an unattended run tells the model when a question needed a person.
117pub const UNATTENDED_NO_ANSWER: &str =
118    "[unattended run] No user was available to answer (--yolo). Decide for yourself and continue.";
119
120/// The answer an unattended run (`--yolo`) gives a request nobody is there to
121/// see.
122///
123/// `--yolo` means "run without a human", so a prompt that blocks on one would
124/// park the run forever - a headless run would hang at the first
125/// `ask_user_confirm`.
126///
127/// A confirmation is approved: that is exactly what the flag promises. A
128/// *choice* is deliberately **not** made - picking option 0 unseen could select
129/// "Abort" or a destructive branch - so the model is told no one answered and
130/// left to decide. An edit submits the document unchanged, and a document put up
131/// for review is acknowledged without comment (a review is a `FreeText` request
132/// carrying a `body`; a question is one without).
133pub fn unattended_answer(req: &InteractionRequest) -> InteractionResponse {
134    use leviath_core::interaction::InteractionKind;
135    match req.kind {
136        InteractionKind::Confirm | InteractionKind::ToolApproval => {
137            InteractionResponse::approval(&req.id, true, ApprovalScope::Once)
138        }
139        InteractionKind::EditText => {
140            InteractionResponse::text(&req.id, req.body.clone().unwrap_or_default())
141        }
142        InteractionKind::FreeText if req.body.is_some() => InteractionResponse::text(&req.id, ""),
143        InteractionKind::FreeText | InteractionKind::MultipleChoice => {
144            InteractionResponse::text(&req.id, UNATTENDED_NO_ANSWER)
145        }
146    }
147}
148
149/// An [`InteractionBackend`] for unattended runs: answers every request from
150/// [`unattended_answer`] instead of opening a prompt on the hub.
151pub struct UnattendedInteraction;
152
153#[async_trait]
154impl InteractionBackend for UnattendedInteraction {
155    async fn ask(&self, req: InteractionRequest) -> InteractionResponse {
156        unattended_answer(&req)
157    }
158}
159
160/// The tools that suspend the agent until a person answers.
161///
162/// Every name here is handled by [`dispatch_dynamic_interaction`] below, which
163/// hands the call to the interaction backend and awaits a human response - so a
164/// stage that offers one of these with nobody attached parks there for as long
165/// as the run lives. `all_dynamic_interaction_tool_names_are_handled` iterates
166/// this list, so the two cannot drift.
167///
168/// Blueprint linting reads it to flag an autonomous stage that grants one.
169pub const BLOCKING_INTERACTION_TOOLS: &[&str] = &[
170    "present_for_review",
171    "ask_user_text",
172    "ask_user_choice",
173    "ask_user_confirm",
174    "edit_document",
175];
176
177/// Dispatch a single dynamic-interaction tool call.
178///
179/// Returns `Some(result_string)` if `tool_name` is one of
180/// `present_for_review` / `ask_user_text` / `ask_user_choice` /
181/// `ask_user_confirm` (and was therefore handled here); returns `None` for
182/// any other tool name so the caller can fall through to normal tool dispatch.
183pub async fn dispatch_dynamic_interaction(
184    backend: &dyn InteractionBackend,
185    tool_name: &str,
186    tool_call_id: &str,
187    arguments: &serde_json::Value,
188    stage_name: &str,
189) -> Option<String> {
190    match tool_name {
191        "present_for_review" => {
192            Some(handle_present_for_review(backend, tool_call_id, arguments, stage_name).await)
193        }
194        "ask_user_text" => {
195            Some(handle_ask_user_text(backend, tool_call_id, arguments, stage_name).await)
196        }
197        "ask_user_choice" => {
198            Some(handle_ask_user_choice(backend, tool_call_id, arguments, stage_name).await)
199        }
200        "ask_user_confirm" => {
201            Some(handle_ask_user_confirm(backend, tool_call_id, arguments, stage_name).await)
202        }
203        "edit_document" => {
204            Some(handle_edit_document(backend, tool_call_id, arguments, stage_name).await)
205        }
206        _ => None,
207    }
208}
209
210fn arg_str<'a>(arguments: &'a serde_json::Value, key: &str, default: &'a str) -> String {
211    arguments
212        .get(key)
213        .and_then(|v| v.as_str())
214        .unwrap_or(default)
215        .to_string()
216}
217
218async fn handle_present_for_review(
219    backend: &dyn InteractionBackend,
220    tool_call_id: &str,
221    arguments: &serde_json::Value,
222    stage_name: &str,
223) -> String {
224    let title = arg_str(arguments, "title", "Review");
225    let markdown = arg_str(arguments, "markdown", "");
226
227    backend.on_review_document(tool_call_id, &title, &markdown);
228    backend.log(&format!(
229        "[tool] present_for_review \u{2192} waiting for user review: {}",
230        title
231    ));
232
233    let req = InteractionRequest::review(
234        format!("review-{}", tool_call_id),
235        &title,
236        &markdown,
237        stage_name,
238    );
239    let resp = backend.ask(req).await;
240    let user_feedback = response_as_text(&resp);
241
242    backend.log("[tool] present_for_review \u{2192} done");
243
244    if user_feedback.trim().is_empty() {
245        "User reviewed the document and acknowledged.".to_string()
246    } else {
247        format!("User feedback: {}", user_feedback)
248    }
249}
250
251async fn handle_ask_user_text(
252    backend: &dyn InteractionBackend,
253    tool_call_id: &str,
254    arguments: &serde_json::Value,
255    stage_name: &str,
256) -> String {
257    let prompt = arg_str(arguments, "prompt", "");
258
259    backend.log(&format!(
260        "[tool] ask_user_text \u{2192} waiting: {}",
261        prompt
262    ));
263
264    let req =
265        InteractionRequest::free_text(format!("ask-{}", tool_call_id), &prompt, stage_name, true);
266    let resp = backend.ask(req).await;
267    let answer = response_as_text(&resp);
268
269    backend.log("[tool] ask_user_text \u{2192} done");
270
271    if answer.trim().is_empty() {
272        "User provided no answer.".to_string()
273    } else {
274        answer
275    }
276}
277
278async fn handle_ask_user_choice(
279    backend: &dyn InteractionBackend,
280    tool_call_id: &str,
281    arguments: &serde_json::Value,
282    stage_name: &str,
283) -> String {
284    let prompt = arg_str(arguments, "prompt", "");
285    let options: Vec<String> = arguments
286        .get("options")
287        .and_then(|v| v.as_array())
288        .map(|arr| {
289            arr.iter()
290                .filter_map(|v| v.as_str().map(|s| s.to_string()))
291                .collect()
292        })
293        .unwrap_or_default();
294
295    if options.len() < 2 {
296        return "[error] ask_user_choice requires at least 2 options".to_string();
297    }
298
299    backend.log(&format!(
300        "[tool] ask_user_choice \u{2192} waiting: {}",
301        prompt
302    ));
303
304    let req = InteractionRequest::multiple_choice(
305        format!("ask-{}", tool_call_id),
306        &prompt,
307        options.clone(),
308        stage_name,
309    );
310    let resp = backend.ask(req).await;
311    let choice = response_as_choice(&resp, &options)
312        .cloned()
313        .unwrap_or_else(|| response_as_text(&resp));
314
315    backend.log("[tool] ask_user_choice \u{2192} done");
316
317    format!("User chose: {}", choice)
318}
319
320async fn handle_ask_user_confirm(
321    backend: &dyn InteractionBackend,
322    tool_call_id: &str,
323    arguments: &serde_json::Value,
324    stage_name: &str,
325) -> String {
326    let prompt = arg_str(arguments, "prompt", "");
327
328    backend.log(&format!(
329        "[tool] ask_user_confirm \u{2192} waiting: {}",
330        prompt
331    ));
332
333    let req = InteractionRequest::confirm(format!("ask-{}", tool_call_id), &prompt, stage_name);
334    let resp = backend.ask(req).await;
335    let approved = response_approved(&resp);
336
337    backend.log("[tool] ask_user_confirm \u{2192} done");
338
339    format!("User answered: {}", if approved { "Yes" } else { "No" })
340}
341
342async fn handle_edit_document(
343    backend: &dyn InteractionBackend,
344    tool_call_id: &str,
345    arguments: &serde_json::Value,
346    stage_name: &str,
347) -> String {
348    let content = arg_str(arguments, "content", "");
349    let prompt = arg_str(
350        arguments,
351        "prompt",
352        "Edit the document below, then submit your changes:",
353    );
354
355    backend.log("[tool] edit_document \u{2192} waiting for user edits");
356
357    let req = InteractionRequest::edit_text(
358        format!("edit-{}", tool_call_id),
359        &prompt,
360        stage_name,
361        &content,
362    );
363    let resp = backend.ask(req).await;
364    let edited = response_as_text(&resp);
365
366    backend.log("[tool] edit_document \u{2192} done");
367
368    if edited.trim().is_empty() {
369        format!("User made no changes. Current document:\n{}", content)
370    } else {
371        format!("User-edited document:\n{}", edited)
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use std::sync::Mutex;
379
380    /// Records every `ask()` request and `log()`/`on_review_document()` call,
381    /// and returns a pre-scripted response for each `ask()` in order.
382    #[derive(Default)]
383    struct MockBackend {
384        responses: Mutex<Vec<InteractionResponse>>,
385        asked: Mutex<Vec<InteractionRequest>>,
386        logs: Mutex<Vec<String>>,
387        reviews: Mutex<Vec<(String, String, String)>>,
388    }
389
390    impl MockBackend {
391        fn with_responses(responses: Vec<InteractionResponse>) -> Self {
392            Self {
393                responses: Mutex::new(responses),
394                ..Default::default()
395            }
396        }
397    }
398
399    #[async_trait]
400    impl InteractionBackend for MockBackend {
401        async fn ask(&self, req: InteractionRequest) -> InteractionResponse {
402            self.asked.lock().unwrap().push(req);
403            let mut responses = self.responses.lock().unwrap();
404            if responses.is_empty() {
405                InteractionResponse::text("", "")
406            } else {
407                responses.remove(0)
408            }
409        }
410
411        fn log(&self, message: &str) {
412            self.logs.lock().unwrap().push(message.to_string());
413        }
414
415        fn on_review_document(&self, tool_call_id: &str, title: &str, markdown: &str) {
416            self.reviews.lock().unwrap().push((
417                tool_call_id.to_string(),
418                title.to_string(),
419                markdown.to_string(),
420            ));
421        }
422    }
423
424    // ─── dispatch_dynamic_interaction: routing ─────────────────────────────
425
426    #[tokio::test]
427    async fn dispatch_unknown_tool_returns_none() {
428        let backend = MockBackend::default();
429        let result = dispatch_dynamic_interaction(
430            &backend,
431            "read_file",
432            "id1",
433            &serde_json::json!({}),
434            "main",
435        )
436        .await;
437        assert!(result.is_none());
438        assert!(backend.asked.lock().unwrap().is_empty());
439    }
440
441    #[tokio::test]
442    async fn all_dynamic_interaction_tool_names_are_handled() {
443        for name in BLOCKING_INTERACTION_TOOLS.iter().copied() {
444            let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "ok")]);
445            let result = dispatch_dynamic_interaction(
446                &backend,
447                name,
448                "id1",
449                &serde_json::json!({"title": "t", "markdown": "m", "prompt": "p", "options": ["A", "B"]}),
450                "main",
451            )
452            .await;
453            assert!(result.is_some());
454        }
455    }
456
457    // ─── present_for_review ─────────────────────────────────────────────────
458
459    #[tokio::test]
460    async fn present_for_review_persists_document_before_asking() {
461        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
462        let result = dispatch_dynamic_interaction(
463            &backend,
464            "present_for_review",
465            "call1",
466            &serde_json::json!({"title": "My Plan", "markdown": "# Plan\ndetails"}),
467            "plan",
468        )
469        .await
470        .unwrap();
471
472        assert_eq!(result, "User reviewed the document and acknowledged.");
473        let reviews = backend.reviews.lock().unwrap();
474        assert_eq!(reviews.len(), 1);
475        assert_eq!(reviews[0].0, "call1");
476        assert_eq!(reviews[0].1, "My Plan");
477        assert_eq!(reviews[0].2, "# Plan\ndetails");
478    }
479
480    #[tokio::test]
481    async fn present_for_review_returns_feedback_when_given() {
482        let backend =
483            MockBackend::with_responses(vec![InteractionResponse::text("", "looks great")]);
484        let result = dispatch_dynamic_interaction(
485            &backend,
486            "present_for_review",
487            "call2",
488            &serde_json::json!({"title": "Design", "markdown": "body"}),
489            "plan",
490        )
491        .await
492        .unwrap();
493        assert_eq!(result, "User feedback: looks great");
494    }
495
496    #[tokio::test]
497    async fn present_for_review_defaults_missing_title_and_markdown() {
498        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
499        dispatch_dynamic_interaction(
500            &backend,
501            "present_for_review",
502            "call3",
503            &serde_json::json!({}),
504            "plan",
505        )
506        .await;
507        let reviews = backend.reviews.lock().unwrap();
508        assert_eq!(reviews[0].1, "Review");
509        assert_eq!(reviews[0].2, "");
510    }
511
512    #[tokio::test]
513    async fn present_for_review_builds_review_kind_request() {
514        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
515        dispatch_dynamic_interaction(
516            &backend,
517            "present_for_review",
518            "call4",
519            &serde_json::json!({"title": "T", "markdown": "M"}),
520            "plan",
521        )
522        .await;
523        let asked = backend.asked.lock().unwrap();
524        assert_eq!(asked.len(), 1);
525        assert_eq!(asked[0].id, "review-call4");
526        assert_eq!(asked[0].prompt, "T");
527        assert_eq!(asked[0].body.as_deref(), Some("M"));
528        assert_eq!(
529            asked[0].body_format,
530            leviath_core::interaction::BodyFormat::Markdown
531        );
532        assert_eq!(asked[0].stage_name, "plan");
533    }
534
535    #[tokio::test]
536    async fn present_for_review_logs_waiting_and_done() {
537        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
538        dispatch_dynamic_interaction(
539            &backend,
540            "present_for_review",
541            "call5",
542            &serde_json::json!({"title": "T", "markdown": "M"}),
543            "plan",
544        )
545        .await;
546        let logs = backend.logs.lock().unwrap();
547        assert!(logs[0].contains("waiting for user review: T"));
548        assert!(logs[1].contains("done"));
549    }
550
551    // ─── ask_user_text ──────────────────────────────────────────────────────
552
553    #[tokio::test]
554    async fn ask_user_text_returns_answer() {
555        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "blue")]);
556        let result = dispatch_dynamic_interaction(
557            &backend,
558            "ask_user_text",
559            "call1",
560            &serde_json::json!({"prompt": "What color?"}),
561            "plan",
562        )
563        .await
564        .unwrap();
565        assert_eq!(result, "blue");
566    }
567
568    #[tokio::test]
569    async fn ask_user_text_empty_answer_reports_no_answer() {
570        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "  ")]);
571        let result = dispatch_dynamic_interaction(
572            &backend,
573            "ask_user_text",
574            "call2",
575            &serde_json::json!({"prompt": "Anything?"}),
576            "plan",
577        )
578        .await
579        .unwrap();
580        assert_eq!(result, "User provided no answer.");
581    }
582
583    #[tokio::test]
584    async fn ask_user_text_builds_free_text_required_request() {
585        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "x")]);
586        dispatch_dynamic_interaction(
587            &backend,
588            "ask_user_text",
589            "call3",
590            &serde_json::json!({"prompt": "Q?"}),
591            "implement",
592        )
593        .await;
594        let asked = backend.asked.lock().unwrap();
595        assert_eq!(asked[0].id, "ask-call3");
596        assert_eq!(asked[0].prompt, "Q?");
597        assert!(asked[0].required);
598        assert_eq!(
599            asked[0].kind,
600            leviath_core::interaction::InteractionKind::FreeText
601        );
602        assert_eq!(asked[0].stage_name, "implement");
603    }
604
605    #[tokio::test]
606    async fn ask_user_text_missing_prompt_defaults_empty() {
607        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "x")]);
608        dispatch_dynamic_interaction(
609            &backend,
610            "ask_user_text",
611            "call4",
612            &serde_json::json!({}),
613            "plan",
614        )
615        .await;
616        let asked = backend.asked.lock().unwrap();
617        assert_eq!(asked[0].prompt, "");
618    }
619
620    // ─── ask_user_choice ────────────────────────────────────────────────────
621
622    #[tokio::test]
623    async fn ask_user_choice_returns_chosen_option() {
624        let backend = MockBackend::with_responses(vec![InteractionResponse::choice("", 1)]);
625        let result = dispatch_dynamic_interaction(
626            &backend,
627            "ask_user_choice",
628            "call1",
629            &serde_json::json!({"prompt": "Pick one", "options": ["A", "B", "C"]}),
630            "plan",
631        )
632        .await
633        .unwrap();
634        assert_eq!(result, "User chose: B");
635    }
636
637    #[tokio::test]
638    async fn ask_user_choice_falls_back_to_text_when_no_choice_index() {
639        let backend =
640            MockBackend::with_responses(vec![InteractionResponse::text("", "custom answer")]);
641        let result = dispatch_dynamic_interaction(
642            &backend,
643            "ask_user_choice",
644            "call2",
645            &serde_json::json!({"prompt": "Pick one", "options": ["A", "B"]}),
646            "plan",
647        )
648        .await
649        .unwrap();
650        assert_eq!(result, "User chose: custom answer");
651    }
652
653    #[tokio::test]
654    async fn ask_user_choice_rejects_fewer_than_two_options() {
655        let backend = MockBackend::default();
656        let result = dispatch_dynamic_interaction(
657            &backend,
658            "ask_user_choice",
659            "call3",
660            &serde_json::json!({"prompt": "Pick one", "options": ["A"]}),
661            "plan",
662        )
663        .await
664        .unwrap();
665        assert_eq!(
666            result,
667            "[error] ask_user_choice requires at least 2 options"
668        );
669        // Must not have asked the user anything for an invalid call.
670        assert!(backend.asked.lock().unwrap().is_empty());
671    }
672
673    #[tokio::test]
674    async fn ask_user_choice_rejects_missing_options() {
675        let backend = MockBackend::default();
676        let result = dispatch_dynamic_interaction(
677            &backend,
678            "ask_user_choice",
679            "call4",
680            &serde_json::json!({"prompt": "Pick one"}),
681            "plan",
682        )
683        .await
684        .unwrap();
685        assert_eq!(
686            result,
687            "[error] ask_user_choice requires at least 2 options"
688        );
689    }
690
691    #[tokio::test]
692    async fn ask_user_choice_builds_multiple_choice_request() {
693        let backend = MockBackend::with_responses(vec![InteractionResponse::choice("", 0)]);
694        dispatch_dynamic_interaction(
695            &backend,
696            "ask_user_choice",
697            "call5",
698            &serde_json::json!({"prompt": "Q?", "options": ["X", "Y"]}),
699            "plan",
700        )
701        .await;
702        let asked = backend.asked.lock().unwrap();
703        assert_eq!(asked[0].id, "ask-call5");
704        assert_eq!(
705            asked[0].kind,
706            leviath_core::interaction::InteractionKind::MultipleChoice
707        );
708        assert_eq!(asked[0].options, vec!["X".to_string(), "Y".to_string()]);
709    }
710
711    // ─── ask_user_confirm ───────────────────────────────────────────────────
712
713    #[tokio::test]
714    async fn ask_user_confirm_yes() {
715        let backend = MockBackend::with_responses(vec![InteractionResponse::approval(
716            "",
717            true,
718            leviath_core::interaction::ApprovalScope::Once,
719        )]);
720        let result = dispatch_dynamic_interaction(
721            &backend,
722            "ask_user_confirm",
723            "call1",
724            &serde_json::json!({"prompt": "Proceed?"}),
725            "implement",
726        )
727        .await
728        .unwrap();
729        assert_eq!(result, "User answered: Yes");
730    }
731
732    #[tokio::test]
733    async fn ask_user_confirm_no() {
734        let backend = MockBackend::with_responses(vec![InteractionResponse::approval(
735            "",
736            false,
737            leviath_core::interaction::ApprovalScope::Once,
738        )]);
739        let result = dispatch_dynamic_interaction(
740            &backend,
741            "ask_user_confirm",
742            "call2",
743            &serde_json::json!({"prompt": "Proceed?"}),
744            "implement",
745        )
746        .await
747        .unwrap();
748        assert_eq!(result, "User answered: No");
749    }
750
751    #[tokio::test]
752    async fn ask_user_confirm_defaults_to_no_when_unanswered() {
753        // response_approved() defaults false for a response with no `approved` set.
754        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
755        let result = dispatch_dynamic_interaction(
756            &backend,
757            "ask_user_confirm",
758            "call3",
759            &serde_json::json!({"prompt": "Proceed?"}),
760            "implement",
761        )
762        .await
763        .unwrap();
764        assert_eq!(result, "User answered: No");
765    }
766
767    #[tokio::test]
768    async fn ask_user_confirm_builds_confirm_request() {
769        let backend = MockBackend::with_responses(vec![InteractionResponse::approval(
770            "",
771            true,
772            leviath_core::interaction::ApprovalScope::Once,
773        )]);
774        dispatch_dynamic_interaction(
775            &backend,
776            "ask_user_confirm",
777            "call4",
778            &serde_json::json!({"prompt": "Sure?"}),
779            "implement",
780        )
781        .await;
782        let asked = backend.asked.lock().unwrap();
783        assert_eq!(asked[0].id, "ask-call4");
784        assert_eq!(
785            asked[0].kind,
786            leviath_core::interaction::InteractionKind::Confirm
787        );
788        assert_eq!(asked[0].options, vec!["Yes".to_string(), "No".to_string()]);
789    }
790
791    // ─── edit_document ──────────────────────────────────────────────────────
792
793    #[tokio::test]
794    async fn edit_document_returns_edited_text() {
795        let backend =
796            MockBackend::with_responses(vec![InteractionResponse::text("", "edited plan")]);
797        let result = dispatch_dynamic_interaction(
798            &backend,
799            "edit_document",
800            "call1",
801            &serde_json::json!({"content": "original plan"}),
802            "plan",
803        )
804        .await
805        .unwrap();
806
807        assert_eq!(result, "User-edited document:\nedited plan");
808        let asked = backend.asked.lock().unwrap();
809        assert_eq!(asked[0].id, "edit-call1");
810        assert_eq!(
811            asked[0].kind,
812            leviath_core::interaction::InteractionKind::EditText
813        );
814        assert_eq!(asked[0].body.as_deref(), Some("original plan"));
815    }
816
817    #[tokio::test]
818    async fn edit_document_empty_edit_returns_original_content() {
819        let backend = MockBackend::with_responses(vec![InteractionResponse::text("", "")]);
820        let result = dispatch_dynamic_interaction(
821            &backend,
822            "edit_document",
823            "call2",
824            &serde_json::json!({"content": "keep this"}),
825            "plan",
826        )
827        .await
828        .unwrap();
829        assert_eq!(result, "User made no changes. Current document:\nkeep this");
830    }
831
832    // ─── log() / on_review_document() default no-ops don't panic ──────────
833
834    struct NoopBackend;
835
836    #[async_trait]
837    impl InteractionBackend for NoopBackend {
838        async fn ask(&self, _req: InteractionRequest) -> InteractionResponse {
839            InteractionResponse::text("", "answer")
840        }
841    }
842
843    #[tokio::test]
844    async fn default_log_and_review_hooks_are_noop_and_safe() {
845        let backend = NoopBackend;
846        let result = dispatch_dynamic_interaction(
847            &backend,
848            "ask_user_text",
849            "call1",
850            &serde_json::json!({"prompt": "Q?"}),
851            "plan",
852        )
853        .await;
854        assert_eq!(result, Some("answer".to_string()));
855
856        let result = dispatch_dynamic_interaction(
857            &backend,
858            "present_for_review",
859            "call2",
860            &serde_json::json!({"title": "T", "markdown": "M"}),
861            "plan",
862        )
863        .await;
864        assert_eq!(result, Some("User feedback: answer".to_string()));
865    }
866
867    // ─── taint-gate prompt helpers ──────────────────────────────────────────
868
869    fn blocked_decision(tool: &str) -> leviath_core::taint::GateDecision {
870        leviath_core::taint::GateDecision::Blocked {
871            taint_level: leviath_core::TaintLevel::Private,
872            clearance: leviath_core::TaintLevel::Public,
873            source_regions: vec!["notes".into()],
874            tool_name: tool.to_string(),
875        }
876    }
877
878    #[test]
879    fn gate_block_info_extracts_blocked_fields() {
880        let (tool, taint, clearance) = gate_block_info(&blocked_decision("shell")).unwrap();
881        assert_eq!(tool, "shell");
882        assert_eq!(taint, leviath_core::TaintLevel::Private);
883        assert_eq!(clearance, leviath_core::TaintLevel::Public);
884        // Allowed decisions yield None.
885        assert!(gate_block_info(&leviath_core::taint::GateDecision::Allowed).is_none());
886    }
887
888    #[test]
889    fn gate_prompt_args_mentions_tool() {
890        let args = gate_prompt_args(
891            "send_email",
892            leviath_core::TaintLevel::Private,
893            leviath_core::TaintLevel::Public,
894        );
895        assert_eq!(args["taint_gate"], true);
896        assert!(args["reason"].as_str().unwrap().contains("send_email"));
897    }
898
899    #[test]
900    fn map_gate_approval_covers_all_outcomes() {
901        use crate::taint::GateResolution;
902        assert_eq!(map_gate_approval(false, false), GateResolution::Deny);
903        assert_eq!(map_gate_approval(false, true), GateResolution::Deny);
904        assert_eq!(map_gate_approval(true, false), GateResolution::AllowOnce);
905        assert_eq!(map_gate_approval(true, true), GateResolution::AlwaysAllow);
906    }
907
908    #[test]
909    fn resolve_gate_with_asker_maps_response() {
910        use crate::taint::GateResolution;
911        // Deny.
912        let r = resolve_gate_with_asker(&blocked_decision("shell"), "plan", |_req| {
913            InteractionResponse::approval("", false, ApprovalScope::Once)
914        });
915        assert_eq!(r, GateResolution::Deny);
916        // Always-allow (session scope). Also assert the request the asker saw is
917        // a taint-gate tool-approval for the right tool.
918        let r = resolve_gate_with_asker(&blocked_decision("shell"), "plan", |req| {
919            assert_eq!(req.tool_name.as_deref(), Some("shell"));
920            assert_eq!(req.stage_name, "plan");
921            InteractionResponse::approval("", true, ApprovalScope::Run)
922        });
923        assert_eq!(r, GateResolution::AlwaysAllow);
924        // A text response (no approval) denies. Bind the asker as a fn pointer
925        // (Copy) so its body is exercised here, then reuse it below where the
926        // short-circuit means it is never invoked.
927        let text_asker: fn(&InteractionRequest) -> InteractionResponse =
928            |_req| InteractionResponse::text("", "");
929        let denied = resolve_gate_with_asker(&blocked_decision("shell"), "plan", text_asker);
930        assert_eq!(denied, GateResolution::Deny);
931        // A non-block decision short-circuits to AllowOnce without asking - the
932        // (already-covered) asker is never invoked.
933        let r = resolve_gate_with_asker(
934            &leviath_core::taint::GateDecision::Allowed,
935            "plan",
936            text_asker,
937        );
938        assert_eq!(r, GateResolution::AllowOnce);
939    }
940
941    // ── unattended (--yolo) answers ───────────────────────────────────────
942
943    #[tokio::test]
944    async fn unattended_answers_every_prompt_without_a_hub() {
945        // Issue #107: `--yolo` means "run without a human", so a prompt that
946        // waits for one parks the run forever. Every dynamic-interaction tool
947        // must come back with something the model can act on.
948        let backend = UnattendedInteraction;
949
950        // A confirmation is approved - that is what the flag promises.
951        let confirmed = dispatch_dynamic_interaction(
952            &backend,
953            "ask_user_confirm",
954            "c1",
955            &serde_json::json!({"prompt": "Delete the branch?"}),
956            "implement",
957        )
958        .await
959        .unwrap();
960        assert_eq!(confirmed, "User answered: Yes");
961
962        // A *choice* is deliberately left unmade: picking an option unseen could
963        // select "Abort" or a destructive branch, so the model is told nobody
964        // answered and decides for itself.
965        let chosen = dispatch_dynamic_interaction(
966            &backend,
967            "ask_user_choice",
968            "c2",
969            &serde_json::json!({"prompt": "Which?", "options": ["Ship it", "Abort"]}),
970            "implement",
971        )
972        .await
973        .unwrap();
974        assert!(chosen.contains(UNATTENDED_NO_ANSWER), "got: {chosen}");
975        assert!(!chosen.contains("Abort"), "no option may be picked blind");
976
977        // Free text says so plainly.
978        let answered = dispatch_dynamic_interaction(
979            &backend,
980            "ask_user_text",
981            "c3",
982            &serde_json::json!({"prompt": "Which database?"}),
983            "implement",
984        )
985        .await
986        .unwrap();
987        assert_eq!(answered, UNATTENDED_NO_ANSWER);
988
989        // A review is acknowledged, and an edit submits the document unchanged.
990        let reviewed = dispatch_dynamic_interaction(
991            &backend,
992            "present_for_review",
993            "c4",
994            &serde_json::json!({"title": "Plan", "markdown": "# Plan"}),
995            "plan",
996        )
997        .await
998        .unwrap();
999        assert!(reviewed.contains("acknowledged"), "got: {reviewed}");
1000
1001        let edited = dispatch_dynamic_interaction(
1002            &backend,
1003            "edit_document",
1004            "c5",
1005            &serde_json::json!({"content": "keep me"}),
1006            "plan",
1007        )
1008        .await
1009        .unwrap();
1010        assert!(edited.contains("keep me"), "got: {edited}");
1011    }
1012
1013    #[test]
1014    fn unattended_answer_approves_a_tool_approval() {
1015        // The tool-policy layer normally short-circuits these under --yolo, so
1016        // cover the arm directly.
1017        let req =
1018            InteractionRequest::tool_approval("t1", "shell", serde_json::json!({}), "impl", &[]);
1019        let resp = unattended_answer(&req);
1020        assert!(leviath_core::interaction::response_approved(&resp));
1021        assert_eq!(resp.scope, Some(ApprovalScope::Once));
1022    }
1023}