Skip to main content

leviath_core/
interaction.rs

1//! Plain value types for the worker ↔ dashboard interaction channel.
2//!
3//! These are serde data types shared across the engine (`leviath-runtime`),
4//! the CLI's file-IPC/stdin transports, and the dashboard. The concrete
5//! transport functions (file IPC, stdin) and backends live in `leviath-cli`;
6//! only the wire/value types and their pure resolver helpers live here so the
7//! runtime can reference them without depending on the CLI.
8
9use serde::{Deserialize, Serialize};
10
11// ─── Request ────────────────────────────────────────────────────────────────
12
13/// The kind of interaction being requested.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum InteractionKind {
17    /// Free-form text answer (the default today).
18    FreeText,
19    /// User picks one option from a numbered list.
20    MultipleChoice,
21    /// Yes/no confirmation.
22    Confirm,
23    /// Approve or deny a specific tool call before it executes.
24    ToolApproval,
25    /// Edit a document in place: the request carries the current text in
26    /// `body`; the user edits it and the (possibly modified) text is returned.
27    EditText,
28}
29
30/// Format of an optional rich body attached to an interaction request.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum BodyFormat {
34    /// Plain text body (no special rendering).
35    #[default]
36    Plain,
37    /// Markdown body - rendered via the dashboard's markdown renderer.
38    Markdown,
39}
40
41/// A pending interaction request written by the worker.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct InteractionRequest {
44    /// Unique ID for this request (uuid-lite: timestamp + stage index).
45    pub id: String,
46    /// What kind of answer is expected.
47    pub kind: InteractionKind,
48    /// Prompt text to display to the user.
49    pub prompt: String,
50    /// Options for MultipleChoice (index-labelled).
51    #[serde(default)]
52    pub options: Vec<String>,
53    /// For ToolApproval: the tool name.
54    pub tool_name: Option<String>,
55    /// For ToolApproval: the tool arguments (JSON).
56    pub tool_arguments: Option<serde_json::Value>,
57    /// Whether an answer is mandatory (empty/cancel not allowed).
58    #[serde(default = "crate::default_true")]
59    pub required: bool,
60    /// Stage name that triggered this request.
61    pub stage_name: String,
62    /// Optional rich body (markdown document, plan, etc.) for the user to review.
63    #[serde(default)]
64    pub body: Option<String>,
65    /// Format of the body content.
66    #[serde(default)]
67    pub body_format: BodyFormat,
68}
69
70impl InteractionRequest {
71    /// Create a new free-text request.
72    pub fn free_text(
73        id: impl Into<String>,
74        prompt: impl Into<String>,
75        stage: impl Into<String>,
76        required: bool,
77    ) -> Self {
78        Self {
79            id: id.into(),
80            kind: InteractionKind::FreeText,
81            prompt: prompt.into(),
82            options: vec![],
83            tool_name: None,
84            tool_arguments: None,
85            required,
86            stage_name: stage.into(),
87            body: None,
88            body_format: BodyFormat::Plain,
89        }
90    }
91
92    /// Create a "present for review" request: pauses the run and shows a rich
93    /// markdown document to the user before accepting feedback.
94    pub fn review(
95        id: impl Into<String>,
96        title: impl Into<String>,
97        markdown: impl Into<String>,
98        stage: impl Into<String>,
99    ) -> Self {
100        Self {
101            id: id.into(),
102            kind: InteractionKind::FreeText,
103            prompt: title.into(),
104            options: vec![],
105            tool_name: None,
106            tool_arguments: None,
107            required: true,
108            stage_name: stage.into(),
109            body: Some(markdown.into()),
110            body_format: BodyFormat::Markdown,
111        }
112    }
113
114    /// Create an "edit document" request: shows `initial_content` in an
115    /// editable field pre-seeded with it (via `body`); the user edits it and
116    /// the modified text is returned as the response text.
117    pub fn edit_text(
118        id: impl Into<String>,
119        prompt: impl Into<String>,
120        stage: impl Into<String>,
121        initial_content: impl Into<String>,
122    ) -> Self {
123        Self {
124            id: id.into(),
125            kind: InteractionKind::EditText,
126            prompt: prompt.into(),
127            options: vec![],
128            tool_name: None,
129            tool_arguments: None,
130            required: true,
131            stage_name: stage.into(),
132            body: Some(initial_content.into()),
133            body_format: BodyFormat::Plain,
134        }
135    }
136
137    /// Create a new multiple-choice request.
138    pub fn multiple_choice(
139        id: impl Into<String>,
140        prompt: impl Into<String>,
141        options: Vec<String>,
142        stage: impl Into<String>,
143    ) -> Self {
144        Self {
145            id: id.into(),
146            kind: InteractionKind::MultipleChoice,
147            prompt: prompt.into(),
148            options,
149            tool_name: None,
150            tool_arguments: None,
151            required: true,
152            stage_name: stage.into(),
153            body: None,
154            body_format: BodyFormat::Plain,
155        }
156    }
157
158    /// Create a new confirm request.
159    pub fn confirm(
160        id: impl Into<String>,
161        prompt: impl Into<String>,
162        stage: impl Into<String>,
163    ) -> Self {
164        Self {
165            id: id.into(),
166            kind: InteractionKind::Confirm,
167            prompt: prompt.into(),
168            options: vec!["Yes".to_string(), "No".to_string()],
169            tool_name: None,
170            tool_arguments: None,
171            required: true,
172            stage_name: stage.into(),
173            body: None,
174            body_format: BodyFormat::Plain,
175        }
176    }
177
178    /// Create a new tool-approval request.
179    /// The part of a tool call a person needs to see before approving it.
180    ///
181    /// "Allow tool call: `bash`?" is not a question anyone can answer - it asks
182    /// whether to run *a shell command* without saying which one, so the only
183    /// safe answer is no and the only practical one is yes. The argument that
184    /// decides the answer is the command itself, and for the file tools it is
185    /// the path.
186    ///
187    /// Truncated, because a prompt is a line in a terminal: a heredoc that
188    /// scrolls the decision off screen is the same problem again.
189    fn approval_detail(tool: &str, arguments: &serde_json::Value) -> Option<String> {
190        let field = match tool {
191            "bash" | "shell" => "command",
192            "write_file" | "edit_file" | "read_file" => "path",
193            _ => return None,
194        };
195        let raw = arguments.get(field)?.as_str()?.trim();
196        if raw.is_empty() {
197            return None;
198        }
199        // One line: a multi-line command is summarised by its first line so the
200        // prompt stays readable, with the rest available in `tool_arguments`.
201        let first = raw.lines().next().unwrap_or(raw);
202        let mut shown: String = first.chars().take(120).collect();
203        if shown.chars().count() < first.chars().count() || first.len() < raw.len() {
204            shown.push('…');
205        }
206        Some(format!("`{shown}`"))
207    }
208
209    /// Build a taint-gate approval request.
210    ///
211    /// Shaped like a tool approval - it is the same yes/no with a scope - but
212    /// worded for the decision actually being made. A gate approval clears the
213    /// tool to carry the data, which is not a grant keyed on what the call
214    /// runs, so it offers no per-stage scope and names no keys.
215    pub fn gate_approval(
216        id: impl Into<String>,
217        tool_name: impl Into<String>,
218        arguments: serde_json::Value,
219        stage: impl Into<String>,
220    ) -> Self {
221        let tool = tool_name.into();
222        Self {
223            id: id.into(),
224            kind: InteractionKind::ToolApproval,
225            prompt: format!("Allow tool call: `{tool}`?"),
226            options: vec![
227                "Allow once".to_string(),
228                "Allow for this run".to_string(),
229                "Deny".to_string(),
230            ],
231            tool_name: Some(tool),
232            tool_arguments: Some(arguments),
233            required: true,
234            stage_name: stage.into(),
235            body: None,
236            body_format: BodyFormat::Plain,
237        }
238    }
239
240    /// What a scoped approval of this call would grant, worded for an option
241    /// label. `None` when the call has no reusable key, which is what makes the
242    /// "it will ask again" wording honest rather than a surprise.
243    ///
244    /// Three keys then `+N more`: the point is to show what is being handed
245    /// over, and a label that wraps the terminal shows nothing.
246    fn grant_summary(grant_keys: &[String]) -> Option<String> {
247        if grant_keys.is_empty() {
248            return None;
249        }
250        let named: Vec<&str> = grant_keys
251            .iter()
252            .take(3)
253            .map(|k| k.strip_prefix("shell:").unwrap_or(k))
254            .collect();
255        let mut summary = named.join(", ");
256        if grant_keys.len() > named.len() {
257            summary.push_str(&format!(" +{} more", grant_keys.len() - named.len()));
258        }
259        Some(summary)
260    }
261
262    /// Build a tool-approval request.
263    ///
264    /// `grant_keys` is what a `Stage` or `Run` approval would be remembered
265    /// under, and it goes in the option labels rather than in `body` because
266    /// the dashboard renders only `prompt` and `options` for this kind. Naming
267    /// it matters: a label promising more than the dispatcher will remember -
268    /// "Allow for this session" on a call it degrades to once - has the user
269    /// choose a grant they do not get.
270    ///
271    /// The options are fixed-position - every client maps an index to a scope
272    /// through [`approval_choice`] - so the wording varies and the length does
273    /// not.
274    pub fn tool_approval(
275        id: impl Into<String>,
276        tool_name: impl Into<String>,
277        arguments: serde_json::Value,
278        stage: impl Into<String>,
279        grant_keys: &[String],
280    ) -> Self {
281        let tool = tool_name.into();
282        let prompt = match Self::approval_detail(&tool, &arguments) {
283            Some(detail) => format!("Allow tool call: `{tool}` - {detail}?"),
284            None => format!("Allow tool call: `{tool}`?"),
285        };
286        let (stage_label, run_label) = match Self::grant_summary(grant_keys) {
287            Some(what) => (
288                format!("Allow {what} for this stage"),
289                format!("Allow {what} for this run"),
290            ),
291            None => (
292                "Allow for this stage (nothing reusable - it will ask again)".to_string(),
293                "Allow for this run (nothing reusable - it will ask again)".to_string(),
294            ),
295        };
296        Self {
297            id: id.into(),
298            kind: InteractionKind::ToolApproval,
299            prompt,
300            options: vec![
301                "Allow once".to_string(),
302                stage_label,
303                run_label,
304                "Deny".to_string(),
305                DENY_WITH_FEEDBACK.to_string(),
306            ],
307            tool_name: Some(tool),
308            tool_arguments: Some(arguments),
309            required: true,
310            stage_name: stage.into(),
311            body: None,
312            body_format: BodyFormat::Plain,
313        }
314    }
315
316    /// Whether option `index` of this request is the deny that takes a
317    /// message for the model.
318    ///
319    /// A client that offers it has to open a text box before answering, so it
320    /// needs to know which row that is. Keyed on the label rather than a fixed
321    /// position because the taint gate's approval has no such row, and a
322    /// client that assumed index four on every approval would open the box on
323    /// a request that cannot carry the text.
324    pub fn is_deny_with_feedback(&self, index: usize) -> bool {
325        self.kind == InteractionKind::ToolApproval
326            && self.options.get(index).map(String::as_str) == Some(DENY_WITH_FEEDBACK)
327    }
328}
329
330/// The label of the tool-approval option that denies and tells the model why.
331///
332/// The plain "Deny" hands the model nothing but the refusal, and its next turn
333/// is a guess at what it should have done instead. This one carries a line
334/// from the person into the tool result, so the next turn is a redirect. Every
335/// client renders it from the request's `options`, so this is where the words
336/// live.
337pub const DENY_WITH_FEEDBACK: &str = "Deny with feedback";
338
339/// The scope a tool-approval option index means, or `None` for deny.
340///
341/// One definition, so the dashboard, `lev respond`, the REST endpoint and the
342/// ACP bridge cannot drift from the labels [`InteractionRequest::tool_approval`]
343/// builds. An index past the end denies: an answer this does not recognise must
344/// never approve.
345pub fn approval_choice(index: usize) -> Option<ApprovalScope> {
346    match index {
347        0 => Some(ApprovalScope::Once),
348        1 => Some(ApprovalScope::Stage),
349        2 => Some(ApprovalScope::Run),
350        _ => None,
351    }
352}
353
354// ─── Response ───────────────────────────────────────────────────────────────
355
356/// How far an approval reaches.
357///
358/// The three scopes are what a person actually wants to say. `Once` is "I read
359/// this one". `Stage` is "keep going through the work I just approved", which
360/// expires when the run moves on to different work. `Run` is "I trust this for
361/// the whole task". Nothing persists past the run: a grant written to disk
362/// would outlive the reason the user made it.
363///
364/// What a grant covers is a set of keys derived from the call, not the tool
365/// name - see `leviath_cli::shell_keys`.
366#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
367#[serde(rename_all = "snake_case")]
368pub enum ApprovalScope {
369    /// Just this one call.
370    Once,
371    /// Every later call this covers, until the run leaves the current stage.
372    Stage,
373    /// Every later call this covers, for the rest of the run.
374    ///
375    /// Serialized as `session`, which is the name every client already sends:
376    /// `lev respond --session`, the REST `"scope": "session"`, and the ACP
377    /// `allow-always` option all mean this.
378    #[serde(rename = "session", alias = "run")]
379    Run,
380}
381
382/// A response written by the dashboard (or `lev respond`) to answer the worker.
383#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
384pub struct InteractionResponse {
385    /// Must match `InteractionRequest.id`.
386    pub request_id: String,
387    /// The user's free-text value (FreeText / InteractionPoints).
388    pub value: Option<String>,
389    /// Index into `options` for MultipleChoice / ToolApproval.
390    pub choice_index: Option<usize>,
391    /// Whether a ToolApproval was granted.
392    pub approved: Option<bool>,
393    /// Scope of a tool approval decision.
394    pub scope: Option<ApprovalScope>,
395    /// What the person wants the model to do instead, on a denied tool
396    /// approval. Reaches the model as part of the tool result.
397    ///
398    /// Absent (the default) is the plain deny every existing client sends, so
399    /// an answer written before this field existed still means what it meant.
400    /// Only ever read alongside `approved: Some(false)`: a grant has nothing to
401    /// redirect.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub feedback: Option<String>,
404    /// Files attached to a text answer: what `lev respond --attach` and a
405    /// `@path` in a dashboard reply send. The run stores each one and
406    /// writes it beside the answer's text in the tool result. Absent on
407    /// every answer written before parts existed, and on every answer that
408    /// is not text.
409    #[serde(default, skip_serializing_if = "Vec::is_empty")]
410    pub parts: Vec<crate::mime::InboundPart>,
411}
412
413impl InteractionResponse {
414    /// The same answer, with files attached.
415    pub fn with_parts(mut self, parts: Vec<crate::mime::InboundPart>) -> Self {
416        self.parts = parts;
417        self
418    }
419
420    /// Build a simple text response.
421    pub fn text(request_id: impl Into<String>, value: impl Into<String>) -> Self {
422        Self {
423            request_id: request_id.into(),
424            value: Some(value.into()),
425            choice_index: None,
426            approved: None,
427            scope: None,
428            feedback: None,
429            parts: Vec::new(),
430        }
431    }
432
433    /// Build a choice response (0-based index).
434    pub fn choice(request_id: impl Into<String>, index: usize) -> Self {
435        Self {
436            request_id: request_id.into(),
437            value: None,
438            choice_index: Some(index),
439            approved: None,
440            scope: None,
441            feedback: None,
442            parts: Vec::new(),
443        }
444    }
445
446    /// Build an approval response.
447    pub fn approval(request_id: impl Into<String>, approved: bool, scope: ApprovalScope) -> Self {
448        Self {
449            request_id: request_id.into(),
450            value: None,
451            choice_index: None,
452            approved: Some(approved),
453            scope: Some(scope),
454            feedback: None,
455            parts: Vec::new(),
456        }
457    }
458
459    /// Build a deny that tells the model what to do instead.
460    ///
461    /// The text is trimmed, and an answer that is all whitespace is the plain
462    /// deny: the tool result must not carry an empty "Feedback:" for the model
463    /// to puzzle over.
464    pub fn deny_with_feedback(request_id: impl Into<String>, feedback: &str) -> Self {
465        let feedback = feedback.trim();
466        Self {
467            request_id: request_id.into(),
468            value: None,
469            choice_index: None,
470            approved: Some(false),
471            scope: Some(ApprovalScope::Once),
472            feedback: (!feedback.is_empty()).then(|| feedback.to_string()),
473            parts: Vec::new(),
474        }
475    }
476
477    /// The redirect a denied tool approval carries, if the person wrote one.
478    ///
479    /// `None` on a grant, whatever the field says: feedback beside
480    /// `approved: true` is a client bug, and the model must not be told the
481    /// call it just ran was refused.
482    pub fn deny_feedback(&self) -> Option<&str> {
483        match self.approved {
484            Some(false) => self
485                .feedback
486                .as_deref()
487                .map(str::trim)
488                .filter(|f| !f.is_empty()),
489            _ => None,
490        }
491    }
492}
493
494// ─── Pure resolver helpers ────────────────────────────────────────────────────
495
496/// Resolve a `FreeText` response to a string.
497pub fn response_as_text(resp: &InteractionResponse) -> String {
498    resp.value.clone().unwrap_or_default()
499}
500
501/// Resolve a `MultipleChoice` response to the chosen option string.
502pub fn response_as_choice<'a>(
503    resp: &InteractionResponse,
504    options: &'a [String],
505) -> Option<&'a String> {
506    resp.choice_index.and_then(|i| options.get(i))
507}
508
509/// Returns `true` if a tool-approval response was granted.
510pub fn response_approved(resp: &InteractionResponse) -> bool {
511    resp.approved.unwrap_or(false)
512}
513
514/// Generate a simple monotonic ID from stage index + iteration.
515pub fn make_interaction_id(stage_idx: usize, iteration: usize) -> String {
516    format!("{}-{}", stage_idx, iteration)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    /// "Allow tool call: `bash`?" asks whether to run a shell command without
524    /// saying which one - the only safe answer is no and the only practical one
525    /// is yes, so in practice everything gets approved unread.
526    #[test]
527    fn a_tool_approval_says_what_it_is_asking_about() {
528        let req = InteractionRequest::tool_approval(
529            "id",
530            "bash",
531            serde_json::json!({"command": "rm -rf build && make"}),
532            "implement",
533            &[],
534        );
535        assert!(
536            req.prompt.contains("rm -rf build && make"),
537            "{}",
538            req.prompt
539        );
540
541        // File tools name the path.
542        let req = InteractionRequest::tool_approval(
543            "id",
544            "write_file",
545            serde_json::json!({"path": "src/main.rs", "content": "..."}),
546            "implement",
547            &[],
548        );
549        assert!(req.prompt.contains("src/main.rs"), "{}", req.prompt);
550    }
551
552    /// A prompt is one line in a terminal. A heredoc that scrolls the decision
553    /// off screen is the same problem as showing nothing.
554    #[test]
555    fn a_long_or_multiline_command_is_summarised() {
556        let long = "echo ".to_string() + &"x".repeat(400);
557        let req = InteractionRequest::tool_approval(
558            "id",
559            "bash",
560            serde_json::json!({ "command": long }),
561            "s",
562            &[],
563        );
564        assert!(req.prompt.chars().count() < 200, "{}", req.prompt);
565        assert!(req.prompt.contains('…'), "{}", req.prompt);
566
567        let req = InteractionRequest::tool_approval(
568            "id",
569            "bash",
570            serde_json::json!({"command": "cat <<'EOF' > f\nline two\nEOF"}),
571            "s",
572            &[],
573        );
574        assert!(req.prompt.contains("cat <<'EOF' > f"), "{}", req.prompt);
575        assert!(!req.prompt.contains("line two"), "{}", req.prompt);
576        // The whole thing is still available to a richer UI.
577        assert!(req.tool_arguments.is_some());
578    }
579
580    /// A tool with no argument worth showing keeps the plain question rather
581    /// than gaining an empty pair of backticks.
582    #[test]
583    fn a_tool_without_a_telling_argument_reads_as_before() {
584        let req =
585            InteractionRequest::tool_approval("id", "list_dir", serde_json::json!({}), "s", &[]);
586        assert_eq!(req.prompt, "Allow tool call: `list_dir`?");
587        let req = InteractionRequest::tool_approval(
588            "id",
589            "bash",
590            serde_json::json!({"command": "   "}),
591            "s",
592            &[],
593        );
594        assert_eq!(req.prompt, "Allow tool call: `bash`?");
595        // Present but not a string: still nothing worth showing.
596        let req = InteractionRequest::tool_approval(
597            "id",
598            "bash",
599            serde_json::json!({"command": 42}),
600            "s",
601            &[],
602        );
603        assert_eq!(req.prompt, "Allow tool call: `bash`?");
604    }
605
606    /// "Allow for this session" said nothing about what the session would then
607    /// be allowed to do, and the dispatcher silently degraded it to once when
608    /// the call had no reusable key - so the user chose a grant they did not
609    /// get. The labels now name it.
610    #[test]
611    fn the_scope_options_name_what_they_grant() {
612        let keys = |names: &[&str]| -> Vec<String> {
613            names.iter().map(|n| format!("shell:{n}")).collect()
614        };
615        let req = InteractionRequest::tool_approval(
616            "id",
617            "shell",
618            serde_json::json!({"command": "ls && git status"}),
619            "s",
620            &keys(&["git status", "ls"]),
621        );
622        assert_eq!(req.options[1], "Allow git status, ls for this stage");
623        assert_eq!(req.options[2], "Allow git status, ls for this run");
624
625        // Beyond three, the rest are counted rather than wrapped off screen.
626        let req = InteractionRequest::tool_approval(
627            "id",
628            "shell",
629            serde_json::json!({}),
630            "s",
631            &keys(&["a", "b", "c", "d", "e"]),
632        );
633        assert_eq!(req.options[2], "Allow a, b, c +2 more for this run");
634
635        // A non-shell key is a bare tool name, with no prefix to strip.
636        let req = InteractionRequest::tool_approval(
637            "id",
638            "web_fetch",
639            serde_json::json!({}),
640            "s",
641            &["web_fetch".to_string()],
642        );
643        assert_eq!(req.options[2], "Allow web_fetch for this run");
644    }
645
646    /// A call with no reusable key says so, rather than offering a grant the
647    /// dispatcher will not record.
648    #[test]
649    fn an_unkeyable_call_says_it_will_ask_again() {
650        let req = InteractionRequest::tool_approval(
651            "id",
652            "shell",
653            serde_json::json!({"command": "echo `whoami`"}),
654            "s",
655            &[],
656        );
657        assert!(
658            req.options[1].contains("it will ask again"),
659            "{:?}",
660            req.options
661        );
662        assert!(
663            req.options[2].contains("it will ask again"),
664            "{:?}",
665            req.options
666        );
667    }
668
669    /// The index-to-scope mapping is what every client uses, so it has to match
670    /// the option order exactly, and an index it does not recognise must deny.
671    #[test]
672    fn approval_choice_matches_the_option_order() {
673        let req = InteractionRequest::tool_approval("id", "shell", serde_json::json!({}), "s", &[]);
674        assert_eq!(approval_choice(0), Some(ApprovalScope::Once));
675        assert!(req.options[1].contains("stage"));
676        assert_eq!(approval_choice(1), Some(ApprovalScope::Stage));
677        assert!(req.options[2].contains("run"));
678        assert_eq!(approval_choice(2), Some(ApprovalScope::Run));
679        assert_eq!(req.options[3], "Deny");
680        assert_eq!(approval_choice(3), None);
681        assert_eq!(req.options[4], DENY_WITH_FEEDBACK);
682        assert_eq!(approval_choice(4), None, "feedback is still a deny");
683        assert_eq!(
684            approval_choice(99),
685            None,
686            "an unknown answer must not approve"
687        );
688    }
689
690    /// A gate approval is a different decision, so it keeps its own wording and
691    /// offers no per-stage scope: clearance is not keyed on what a call runs.
692    #[test]
693    fn a_gate_approval_offers_run_scope_and_no_stage_scope() {
694        let req =
695            InteractionRequest::gate_approval("g", "web_fetch", serde_json::json!({}), "research");
696        assert_eq!(req.kind, InteractionKind::ToolApproval);
697        assert_eq!(req.prompt, "Allow tool call: `web_fetch`?");
698        assert_eq!(req.options, ["Allow once", "Allow for this run", "Deny"]);
699    }
700
701    /// `session` stays the wire name for run scope: every client already sends
702    /// it, and renaming it on the wire would silently narrow their grants.
703    #[test]
704    fn run_scope_serialises_as_session() {
705        assert_eq!(
706            serde_json::to_string(&ApprovalScope::Run).unwrap(),
707            "\"session\""
708        );
709        for wire in ["\"session\"", "\"run\""] {
710            let back: ApprovalScope = serde_json::from_str(wire).unwrap();
711            assert_eq!(back, ApprovalScope::Run, "{wire}");
712        }
713        assert_eq!(
714            serde_json::to_string(&ApprovalScope::Stage).unwrap(),
715            "\"stage\""
716        );
717    }
718
719    // ─── InteractionRequest / InteractionResponse constructors ─────────────
720
721    #[test]
722    fn test_request_builders() {
723        let r = InteractionRequest::free_text("id1", "What now?", "plan", true);
724        assert_eq!(r.kind, InteractionKind::FreeText);
725        assert!(r.required);
726
727        let r = InteractionRequest::multiple_choice(
728            "id2",
729            "Pick one",
730            vec!["A".into(), "B".into()],
731            "plan",
732        );
733        assert_eq!(r.kind, InteractionKind::MultipleChoice);
734        assert_eq!(r.options.len(), 2);
735
736        let r = InteractionRequest::tool_approval(
737            "id3",
738            "bash",
739            serde_json::json!({"cmd": "ls"}),
740            "impl",
741            &[],
742        );
743        assert_eq!(r.kind, InteractionKind::ToolApproval);
744        assert_eq!(r.options.len(), 5);
745    }
746
747    #[test]
748    fn test_edit_text_request_builder_seeds_body() {
749        let r = InteractionRequest::edit_text("id4", "Edit this", "plan", "current text");
750        assert_eq!(r.kind, InteractionKind::EditText);
751        assert!(r.required);
752        assert_eq!(r.body.as_deref(), Some("current text"));
753        assert_eq!(r.prompt, "Edit this");
754    }
755
756    #[test]
757    fn test_edit_text_kind_serde_roundtrip_snake_case() {
758        let r = InteractionRequest::edit_text("id5", "p", "plan", "seed");
759        let json = serde_json::to_string(&r).unwrap();
760        // snake_case rename ⇒ "edit_text"
761        assert!(json.contains("\"edit_text\""));
762        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
763        assert_eq!(back.kind, InteractionKind::EditText);
764        assert_eq!(back.body.as_deref(), Some("seed"));
765    }
766
767    #[test]
768    fn test_response_builders() {
769        let r = InteractionResponse::text("id1", "hello");
770        assert_eq!(r.value.as_deref(), Some("hello"));
771
772        let r = InteractionResponse::choice("id2", 1);
773        assert_eq!(r.choice_index, Some(1));
774
775        let r = InteractionResponse::approval("id3", true, ApprovalScope::Run);
776        assert_eq!(r.approved, Some(true));
777        assert_eq!(r.scope, Some(ApprovalScope::Run));
778    }
779
780    /// An answer's files ride the wire only when there are some, so every
781    /// client that never heard of parts reads and writes the same JSON.
782    #[test]
783    fn parts_ride_the_answer_only_when_attached() {
784        let bare = InteractionResponse::text("q1", "hi");
785        let json = serde_json::to_string(&bare).unwrap();
786        assert!(!json.contains("parts"), "{json}");
787        let parsed: InteractionResponse = serde_json::from_str(&json).unwrap();
788        assert!(parsed.parts.is_empty());
789        let with = bare.with_parts(vec![crate::mime::InboundPart::from_bytes(
790            "a.png",
791            vec![1, 2, 3],
792        )]);
793        let json = serde_json::to_string(&with).unwrap();
794        assert!(json.contains("\"parts\""), "{json}");
795        let parsed: InteractionResponse = serde_json::from_str(&json).unwrap();
796        assert_eq!(parsed.parts.len(), 1);
797        assert_eq!(parsed.parts[0].name, "a.png");
798    }
799
800    #[test]
801    fn test_response_as_text() {
802        let r = InteractionResponse::text("id", "answer");
803        assert_eq!(response_as_text(&r), "answer");
804        let empty = InteractionResponse {
805            request_id: "x".into(),
806            value: None,
807            choice_index: None,
808            approved: None,
809            scope: None,
810            feedback: None,
811            parts: Vec::new(),
812        };
813        assert_eq!(response_as_text(&empty), "");
814    }
815
816    #[test]
817    fn test_response_as_choice() {
818        let opts = vec!["Alpha".to_string(), "Beta".to_string()];
819        let r = InteractionResponse::choice("id", 0);
820        assert_eq!(response_as_choice(&r, &opts), Some(&"Alpha".to_string()));
821        let r = InteractionResponse::choice("id", 1);
822        assert_eq!(response_as_choice(&r, &opts), Some(&"Beta".to_string()));
823        let r = InteractionResponse::choice("id", 99);
824        assert!(response_as_choice(&r, &opts).is_none());
825    }
826
827    #[test]
828    fn test_make_interaction_id() {
829        let id = make_interaction_id(2, 5);
830        assert_eq!(id, "2-5");
831    }
832
833    #[test]
834    fn test_free_text_request_not_required() {
835        let r = InteractionRequest::free_text("ft1", "optional?", "stage1", false);
836        assert_eq!(r.kind, InteractionKind::FreeText);
837        assert!(!r.required);
838        assert_eq!(r.id, "ft1");
839        assert_eq!(r.prompt, "optional?");
840        assert_eq!(r.stage_name, "stage1");
841        assert!(r.options.is_empty());
842        assert!(r.tool_name.is_none());
843        assert!(r.tool_arguments.is_none());
844        assert!(r.body.is_none());
845        assert_eq!(r.body_format, BodyFormat::Plain);
846    }
847
848    #[test]
849    fn test_review_request() {
850        let r = InteractionRequest::review("rev1", "Review Title", "# Markdown body", "plan");
851        assert_eq!(r.kind, InteractionKind::FreeText);
852        assert!(r.required);
853        assert_eq!(r.prompt, "Review Title");
854        assert_eq!(r.body.as_deref(), Some("# Markdown body"));
855        assert_eq!(r.body_format, BodyFormat::Markdown);
856        assert_eq!(r.stage_name, "plan");
857    }
858
859    #[test]
860    fn test_confirm_request() {
861        let r = InteractionRequest::confirm("c1", "Proceed?", "deploy");
862        assert_eq!(r.kind, InteractionKind::Confirm);
863        assert_eq!(r.options, vec!["Yes", "No"]);
864        assert!(r.required);
865        assert_eq!(r.stage_name, "deploy");
866    }
867
868    #[test]
869    fn test_tool_approval_request() {
870        let args = serde_json::json!({"file": "test.txt"});
871        let r = InteractionRequest::tool_approval("ta1", "write_file", args, "code", &[]);
872        assert_eq!(r.kind, InteractionKind::ToolApproval);
873        assert_eq!(r.tool_name.as_deref(), Some("write_file"));
874        assert!(r.tool_arguments.is_some());
875        assert_eq!(r.options.len(), 5);
876        assert!(r.prompt.contains("write_file"));
877    }
878
879    #[test]
880    fn test_response_text_empty() {
881        let r = InteractionResponse::text("id", "");
882        assert_eq!(r.value.as_deref(), Some(""));
883        assert!(r.choice_index.is_none());
884        assert!(r.approved.is_none());
885        assert!(r.scope.is_none());
886    }
887
888    #[test]
889    fn test_response_approval_denied() {
890        let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
891        assert_eq!(r.approved, Some(false));
892        assert_eq!(r.scope, Some(ApprovalScope::Once));
893    }
894
895    #[test]
896    fn test_response_approved_true() {
897        let r = InteractionResponse::approval("id", true, ApprovalScope::Run);
898        assert!(response_approved(&r));
899    }
900
901    #[test]
902    fn test_response_approved_false() {
903        let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
904        assert!(!response_approved(&r));
905    }
906
907    #[test]
908    fn test_response_approved_none() {
909        let r = InteractionResponse::text("id", "hello");
910        assert!(!response_approved(&r));
911    }
912
913    #[test]
914    fn test_approval_scope_serde_roundtrip() {
915        for scope in [ApprovalScope::Once, ApprovalScope::Run] {
916            let json = serde_json::to_string(&scope).unwrap();
917            let back: ApprovalScope = serde_json::from_str(&json).unwrap();
918            assert_eq!(scope, back);
919        }
920    }
921
922    #[test]
923    fn test_approval_scope_snake_case() {
924        let json = serde_json::to_string(&ApprovalScope::Once).unwrap();
925        assert_eq!(json, "\"once\"");
926        let json = serde_json::to_string(&ApprovalScope::Run).unwrap();
927        assert_eq!(json, "\"session\"");
928    }
929
930    #[test]
931    fn test_interaction_kind_serde_roundtrip() {
932        for kind in [
933            InteractionKind::FreeText,
934            InteractionKind::MultipleChoice,
935            InteractionKind::Confirm,
936            InteractionKind::ToolApproval,
937        ] {
938            let json = serde_json::to_string(&kind).unwrap();
939            let back: InteractionKind = serde_json::from_str(&json).unwrap();
940            assert_eq!(kind, back);
941        }
942    }
943
944    #[test]
945    fn test_body_format_serde_roundtrip() {
946        for fmt in [BodyFormat::Plain, BodyFormat::Markdown] {
947            let json = serde_json::to_string(&fmt).unwrap();
948            let back: BodyFormat = serde_json::from_str(&json).unwrap();
949            assert_eq!(fmt, back);
950        }
951    }
952
953    #[test]
954    fn test_body_format_default_is_plain() {
955        let fmt = BodyFormat::default();
956        assert_eq!(fmt, BodyFormat::Plain);
957    }
958
959    #[test]
960    fn test_interaction_request_serde_roundtrip() {
961        let req = InteractionRequest::tool_approval(
962            "serde1",
963            "bash",
964            serde_json::json!({"cmd": "ls -la"}),
965            "code",
966            &[],
967        );
968        let json = serde_json::to_string(&req).unwrap();
969        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
970        assert_eq!(back.id, "serde1");
971        assert_eq!(back.kind, InteractionKind::ToolApproval);
972        assert_eq!(back.tool_name.as_deref(), Some("bash"));
973    }
974
975    #[test]
976    fn test_interaction_response_serde_roundtrip() {
977        let resp = InteractionResponse::approval("serde2", true, ApprovalScope::Run);
978        let json = serde_json::to_string(&resp).unwrap();
979        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
980        assert_eq!(back.request_id, "serde2");
981        assert_eq!(back.approved, Some(true));
982        assert_eq!(back.scope, Some(ApprovalScope::Run));
983    }
984
985    #[test]
986    fn test_make_interaction_id_zero() {
987        assert_eq!(make_interaction_id(0, 0), "0-0");
988    }
989
990    #[test]
991    fn test_make_interaction_id_large() {
992        assert_eq!(make_interaction_id(999, 1000), "999-1000");
993    }
994
995    #[test]
996    fn test_response_as_choice_no_choice_index() {
997        let opts = vec!["A".to_string(), "B".to_string()];
998        let r = InteractionResponse::text("id", "hello");
999        assert!(response_as_choice(&r, &opts).is_none());
1000    }
1001
1002    #[test]
1003    fn test_response_as_choice_empty_options() {
1004        let opts: Vec<String> = vec![];
1005        let r = InteractionResponse::choice("id", 0);
1006        assert!(response_as_choice(&r, &opts).is_none());
1007    }
1008
1009    #[test]
1010    fn test_free_text_request_defaults() {
1011        let r = InteractionRequest::free_text("ft", "prompt", "stage", true);
1012        assert!(r.body.is_none());
1013        assert_eq!(r.body_format, BodyFormat::Plain);
1014        assert!(r.tool_name.is_none());
1015        assert!(r.tool_arguments.is_none());
1016        assert!(r.options.is_empty());
1017    }
1018
1019    #[test]
1020    fn test_multiple_choice_request_is_required() {
1021        let r = InteractionRequest::multiple_choice("mc", "Pick", vec!["A".into()], "stage");
1022        assert!(r.required);
1023    }
1024
1025    #[test]
1026    fn test_confirm_request_is_required() {
1027        let r = InteractionRequest::confirm("c", "Sure?", "stage");
1028        assert!(r.required);
1029    }
1030
1031    #[test]
1032    fn test_tool_approval_is_required() {
1033        let r =
1034            InteractionRequest::tool_approval("ta", "bash", serde_json::json!({}), "stage", &[]);
1035        assert!(r.required);
1036    }
1037
1038    #[test]
1039    fn test_interaction_kind_snake_case_values() {
1040        assert_eq!(
1041            serde_json::to_string(&InteractionKind::FreeText).unwrap(),
1042            "\"free_text\""
1043        );
1044        assert_eq!(
1045            serde_json::to_string(&InteractionKind::MultipleChoice).unwrap(),
1046            "\"multiple_choice\""
1047        );
1048        assert_eq!(
1049            serde_json::to_string(&InteractionKind::ToolApproval).unwrap(),
1050            "\"tool_approval\""
1051        );
1052        assert_eq!(
1053            serde_json::to_string(&InteractionKind::Confirm).unwrap(),
1054            "\"confirm\""
1055        );
1056    }
1057
1058    #[test]
1059    fn test_body_format_snake_case_values() {
1060        assert_eq!(
1061            serde_json::to_string(&BodyFormat::Plain).unwrap(),
1062            "\"plain\""
1063        );
1064        assert_eq!(
1065            serde_json::to_string(&BodyFormat::Markdown).unwrap(),
1066            "\"markdown\""
1067        );
1068    }
1069
1070    #[test]
1071    fn test_request_free_text_serde_roundtrip() {
1072        let req = InteractionRequest::free_text("ft1", "What?", "main", false);
1073        let json = serde_json::to_string(&req).unwrap();
1074        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1075        assert_eq!(back.id, "ft1");
1076        assert_eq!(back.kind, InteractionKind::FreeText);
1077        assert!(!back.required);
1078        assert_eq!(back.stage_name, "main");
1079    }
1080
1081    #[test]
1082    fn test_request_multiple_choice_serde_roundtrip() {
1083        let req = InteractionRequest::multiple_choice(
1084            "mc1",
1085            "Choose",
1086            vec!["A".into(), "B".into(), "C".into()],
1087            "plan",
1088        );
1089        let json = serde_json::to_string(&req).unwrap();
1090        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1091        assert_eq!(back.kind, InteractionKind::MultipleChoice);
1092        assert_eq!(back.options.len(), 3);
1093        assert_eq!(back.options[2], "C");
1094    }
1095
1096    #[test]
1097    fn test_request_confirm_serde_roundtrip() {
1098        let req = InteractionRequest::confirm("c1", "Proceed?", "deploy");
1099        let json = serde_json::to_string(&req).unwrap();
1100        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1101        assert_eq!(back.kind, InteractionKind::Confirm);
1102        assert_eq!(back.options, vec!["Yes", "No"]);
1103    }
1104
1105    #[test]
1106    fn test_request_review_serde_roundtrip() {
1107        let req = InteractionRequest::review("rev1", "Title", "# Body\ntext", "review");
1108        let json = serde_json::to_string(&req).unwrap();
1109        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1110        assert_eq!(back.body_format, BodyFormat::Markdown);
1111        assert_eq!(back.body.as_deref(), Some("# Body\ntext"));
1112    }
1113
1114    #[test]
1115    fn test_response_text_serde_roundtrip() {
1116        let resp = InteractionResponse::text("t1", "my answer");
1117        let json = serde_json::to_string(&resp).unwrap();
1118        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1119        assert_eq!(back.request_id, "t1");
1120        assert_eq!(back.value.as_deref(), Some("my answer"));
1121        assert!(back.choice_index.is_none());
1122        assert!(back.approved.is_none());
1123        assert!(back.scope.is_none());
1124    }
1125
1126    #[test]
1127    fn test_response_choice_serde_roundtrip() {
1128        let resp = InteractionResponse::choice("c1", 2);
1129        let json = serde_json::to_string(&resp).unwrap();
1130        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1131        assert_eq!(back.choice_index, Some(2));
1132        assert!(back.value.is_none());
1133    }
1134
1135    #[test]
1136    fn test_response_approval_serde_roundtrip() {
1137        let resp = InteractionResponse::approval("a1", false, ApprovalScope::Run);
1138        let json = serde_json::to_string(&resp).unwrap();
1139        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1140        assert_eq!(back.approved, Some(false));
1141        assert_eq!(back.scope, Some(ApprovalScope::Run));
1142    }
1143
1144    #[test]
1145    fn test_response_as_text_with_value() {
1146        let r = InteractionResponse::text("id", "some text value");
1147        assert_eq!(response_as_text(&r), "some text value");
1148    }
1149
1150    #[test]
1151    fn test_response_approved_session_scope() {
1152        let r = InteractionResponse::approval("id", true, ApprovalScope::Run);
1153        assert!(response_approved(&r));
1154        assert_eq!(r.scope, Some(ApprovalScope::Run));
1155    }
1156
1157    #[test]
1158    fn test_make_interaction_id_various() {
1159        assert_eq!(make_interaction_id(1, 2), "1-2");
1160        assert_eq!(make_interaction_id(10, 20), "10-20");
1161    }
1162
1163    #[test]
1164    fn test_default_true_via_serde_missing_required_field() {
1165        // JSON without `required` - should default to true via default_true()
1166        let json = r#"{
1167            "id": "dt1",
1168            "kind": "free_text",
1169            "prompt": "test prompt",
1170            "stage_name": "stage"
1171        }"#;
1172        let req: InteractionRequest = serde_json::from_str(json).unwrap();
1173        assert!(req.required);
1174    }
1175    // ─── deny with feedback ──────────────────────────────────────────────────
1176
1177    /// The wire shape every existing client sends has no `feedback` key, and
1178    /// the shape a new one sends adds exactly that key and nothing else.
1179    #[test]
1180    fn feedback_is_absent_from_the_wire_unless_given() {
1181        let plain = InteractionResponse::approval("q1", false, ApprovalScope::Once);
1182        let json = serde_json::to_value(&plain).unwrap();
1183        assert!(json.get("feedback").is_none(), "{json}");
1184        let old_wire: InteractionResponse =
1185            serde_json::from_str(r#"{"request_id":"q1","value":null,"choice_index":null,"approved":false,"scope":"once"}"#)
1186                .unwrap();
1187        assert_eq!(old_wire, plain);
1188
1189        let with = InteractionResponse::deny_with_feedback("q1", "  use git log instead \n");
1190        let json = serde_json::to_value(&with).unwrap();
1191        assert_eq!(json["feedback"], "use git log instead");
1192        assert_eq!(json["approved"], false);
1193        let back: InteractionResponse = serde_json::from_value(json).unwrap();
1194        assert_eq!(back, with);
1195        assert_eq!(back.deny_feedback(), Some("use git log instead"));
1196    }
1197
1198    /// Whitespace is not a message: the constructor and the reader both turn
1199    /// it into the plain deny.
1200    #[test]
1201    fn blank_feedback_is_the_plain_deny() {
1202        let blank = InteractionResponse::deny_with_feedback("q1", "   \n\t");
1203        assert_eq!(blank.feedback, None);
1204        assert_eq!(blank.approved, Some(false));
1205        assert_eq!(blank.deny_feedback(), None);
1206        let padded = InteractionResponse {
1207            feedback: Some("  \n ".to_string()),
1208            ..InteractionResponse::approval("q1", false, ApprovalScope::Once)
1209        };
1210        assert_eq!(padded.deny_feedback(), None);
1211    }
1212
1213    /// Feedback beside a grant, or beside no decision at all, is never read.
1214    #[test]
1215    fn feedback_is_only_read_on_a_deny() {
1216        let granted = InteractionResponse {
1217            feedback: Some("why".to_string()),
1218            ..InteractionResponse::approval("q1", true, ApprovalScope::Run)
1219        };
1220        assert_eq!(granted.deny_feedback(), None);
1221        let undecided = InteractionResponse {
1222            feedback: Some("why".to_string()),
1223            ..InteractionResponse::text("q1", "")
1224        };
1225        assert_eq!(undecided.deny_feedback(), None);
1226    }
1227
1228    /// Only a tool approval has the feedback row, and only at the position
1229    /// the label sits at.
1230    #[test]
1231    fn the_feedback_row_is_found_by_label_on_tool_approvals_only() {
1232        let tool =
1233            InteractionRequest::tool_approval("id", "shell", serde_json::json!({}), "s", &[]);
1234        assert!(tool.is_deny_with_feedback(4));
1235        assert!(!tool.is_deny_with_feedback(3));
1236        assert!(!tool.is_deny_with_feedback(99));
1237        let gate = InteractionRequest::gate_approval("id", "web_fetch", serde_json::json!({}), "s");
1238        assert!(!gate.is_deny_with_feedback(2));
1239        assert!(!gate.is_deny_with_feedback(4));
1240        let choice = InteractionRequest::multiple_choice(
1241            "id",
1242            "?",
1243            vec![DENY_WITH_FEEDBACK.to_string()],
1244            "s",
1245        );
1246        assert!(
1247            !choice.is_deny_with_feedback(0),
1248            "the label alone is not the row"
1249        );
1250    }
1251}