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 = "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
70fn default_true() -> bool {
71    true
72}
73
74impl InteractionRequest {
75    /// Create a new free-text request.
76    pub fn free_text(
77        id: impl Into<String>,
78        prompt: impl Into<String>,
79        stage: impl Into<String>,
80        required: bool,
81    ) -> Self {
82        Self {
83            id: id.into(),
84            kind: InteractionKind::FreeText,
85            prompt: prompt.into(),
86            options: vec![],
87            tool_name: None,
88            tool_arguments: None,
89            required,
90            stage_name: stage.into(),
91            body: None,
92            body_format: BodyFormat::Plain,
93        }
94    }
95
96    /// Create a "present for review" request: pauses the run and shows a rich
97    /// markdown document to the user before accepting feedback.
98    pub fn review(
99        id: impl Into<String>,
100        title: impl Into<String>,
101        markdown: impl Into<String>,
102        stage: impl Into<String>,
103    ) -> Self {
104        Self {
105            id: id.into(),
106            kind: InteractionKind::FreeText,
107            prompt: title.into(),
108            options: vec![],
109            tool_name: None,
110            tool_arguments: None,
111            required: true,
112            stage_name: stage.into(),
113            body: Some(markdown.into()),
114            body_format: BodyFormat::Markdown,
115        }
116    }
117
118    /// Create an "edit document" request: shows `initial_content` in an
119    /// editable field pre-seeded with it (via `body`); the user edits it and
120    /// the modified text is returned as the response text.
121    pub fn edit_text(
122        id: impl Into<String>,
123        prompt: impl Into<String>,
124        stage: impl Into<String>,
125        initial_content: impl Into<String>,
126    ) -> Self {
127        Self {
128            id: id.into(),
129            kind: InteractionKind::EditText,
130            prompt: prompt.into(),
131            options: vec![],
132            tool_name: None,
133            tool_arguments: None,
134            required: true,
135            stage_name: stage.into(),
136            body: Some(initial_content.into()),
137            body_format: BodyFormat::Plain,
138        }
139    }
140
141    /// Create a new multiple-choice request.
142    pub fn multiple_choice(
143        id: impl Into<String>,
144        prompt: impl Into<String>,
145        options: Vec<String>,
146        stage: impl Into<String>,
147    ) -> Self {
148        Self {
149            id: id.into(),
150            kind: InteractionKind::MultipleChoice,
151            prompt: prompt.into(),
152            options,
153            tool_name: None,
154            tool_arguments: None,
155            required: true,
156            stage_name: stage.into(),
157            body: None,
158            body_format: BodyFormat::Plain,
159        }
160    }
161
162    /// Create a new confirm request.
163    pub fn confirm(
164        id: impl Into<String>,
165        prompt: impl Into<String>,
166        stage: impl Into<String>,
167    ) -> Self {
168        Self {
169            id: id.into(),
170            kind: InteractionKind::Confirm,
171            prompt: prompt.into(),
172            options: vec!["Yes".to_string(), "No".to_string()],
173            tool_name: None,
174            tool_arguments: None,
175            required: true,
176            stage_name: stage.into(),
177            body: None,
178            body_format: BodyFormat::Plain,
179        }
180    }
181
182    /// Create a new tool-approval request.
183    /// The part of a tool call a person needs to see before approving it.
184    ///
185    /// "Allow tool call: `bash`?" is not a question anyone can answer - it asks
186    /// whether to run *a shell command* without saying which one, so the only
187    /// safe answer is no and the only practical one is yes. The argument that
188    /// decides the answer is the command itself, and for the file tools it is
189    /// the path.
190    ///
191    /// Truncated, because a prompt is a line in a terminal: a heredoc that
192    /// scrolls the decision off screen is the same problem again.
193    fn approval_detail(tool: &str, arguments: &serde_json::Value) -> Option<String> {
194        let field = match tool {
195            "bash" | "shell" => "command",
196            "write_file" | "edit_file" | "read_file" => "path",
197            _ => return None,
198        };
199        let raw = arguments.get(field)?.as_str()?.trim();
200        if raw.is_empty() {
201            return None;
202        }
203        // One line: a multi-line command is summarised by its first line so the
204        // prompt stays readable, with the rest available in `tool_arguments`.
205        let first = raw.lines().next().unwrap_or(raw);
206        let mut shown: String = first.chars().take(120).collect();
207        if shown.chars().count() < first.chars().count() || first.len() < raw.len() {
208            shown.push('…');
209        }
210        Some(format!("`{shown}`"))
211    }
212
213    /// Build a taint-gate approval request.
214    ///
215    /// Shaped like a tool approval - it is the same yes/no with a scope - but
216    /// worded for the decision actually being made. A gate approval clears the
217    /// tool to carry the data, which is not a grant keyed on what the call
218    /// runs, so it offers no per-stage scope and names no keys.
219    pub fn gate_approval(
220        id: impl Into<String>,
221        tool_name: impl Into<String>,
222        arguments: serde_json::Value,
223        stage: impl Into<String>,
224    ) -> Self {
225        let tool = tool_name.into();
226        Self {
227            id: id.into(),
228            kind: InteractionKind::ToolApproval,
229            prompt: format!("Allow tool call: `{tool}`?"),
230            options: vec![
231                "Allow once".to_string(),
232                "Allow for this run".to_string(),
233                "Deny".to_string(),
234            ],
235            tool_name: Some(tool),
236            tool_arguments: Some(arguments),
237            required: true,
238            stage_name: stage.into(),
239            body: None,
240            body_format: BodyFormat::Plain,
241        }
242    }
243
244    /// What a scoped approval of this call would grant, worded for an option
245    /// label. `None` when the call has no reusable key, which is what makes the
246    /// "it will ask again" wording honest rather than a surprise.
247    ///
248    /// Three keys then `+N more`: the point is to show what is being handed
249    /// over, and a label that wraps the terminal shows nothing.
250    fn grant_summary(grant_keys: &[String]) -> Option<String> {
251        if grant_keys.is_empty() {
252            return None;
253        }
254        let named: Vec<&str> = grant_keys
255            .iter()
256            .take(3)
257            .map(|k| k.strip_prefix("shell:").unwrap_or(k))
258            .collect();
259        let mut summary = named.join(", ");
260        if grant_keys.len() > named.len() {
261            summary.push_str(&format!(" +{} more", grant_keys.len() - named.len()));
262        }
263        Some(summary)
264    }
265
266    /// Build a tool-approval request.
267    ///
268    /// `grant_keys` is what a `Stage` or `Run` approval would be remembered
269    /// under, and it goes in the option labels rather than in `body` because
270    /// the dashboard renders only `prompt` and `options` for this kind. Naming
271    /// it matters: the old wording offered "Allow for this session" on calls
272    /// the dispatcher then silently degraded to once, so the user chose a grant
273    /// they did not get.
274    ///
275    /// The options are fixed-position - every client maps an index to a scope
276    /// through [`approval_choice`] - so the wording varies and the length does
277    /// not.
278    pub fn tool_approval(
279        id: impl Into<String>,
280        tool_name: impl Into<String>,
281        arguments: serde_json::Value,
282        stage: impl Into<String>,
283        grant_keys: &[String],
284    ) -> Self {
285        let tool = tool_name.into();
286        let prompt = match Self::approval_detail(&tool, &arguments) {
287            Some(detail) => format!("Allow tool call: `{tool}` - {detail}?"),
288            None => format!("Allow tool call: `{tool}`?"),
289        };
290        let (stage_label, run_label) = match Self::grant_summary(grant_keys) {
291            Some(what) => (
292                format!("Allow {what} for this stage"),
293                format!("Allow {what} for this run"),
294            ),
295            None => (
296                "Allow for this stage (nothing reusable - it will ask again)".to_string(),
297                "Allow for this run (nothing reusable - it will ask again)".to_string(),
298            ),
299        };
300        Self {
301            id: id.into(),
302            kind: InteractionKind::ToolApproval,
303            prompt,
304            options: vec![
305                "Allow once".to_string(),
306                stage_label,
307                run_label,
308                "Deny".to_string(),
309            ],
310            tool_name: Some(tool),
311            tool_arguments: Some(arguments),
312            required: true,
313            stage_name: stage.into(),
314            body: None,
315            body_format: BodyFormat::Plain,
316        }
317    }
318}
319
320/// The scope a tool-approval option index means, or `None` for deny.
321///
322/// One definition, so the dashboard, `lev respond`, the REST endpoint and the
323/// ACP bridge cannot drift from the labels [`InteractionRequest::tool_approval`]
324/// builds. An index past the end denies: an answer this does not recognise must
325/// never approve.
326pub fn approval_choice(index: usize) -> Option<ApprovalScope> {
327    match index {
328        0 => Some(ApprovalScope::Once),
329        1 => Some(ApprovalScope::Stage),
330        2 => Some(ApprovalScope::Run),
331        _ => None,
332    }
333}
334
335// ─── Response ───────────────────────────────────────────────────────────────
336
337/// How far an approval reaches.
338///
339/// The three scopes are what a person actually wants to say. `Once` is "I read
340/// this one". `Stage` is "keep going through the work I just approved", which
341/// expires when the run moves on to different work. `Run` is "I trust this for
342/// the whole task". Nothing persists past the run: a grant written to disk
343/// would outlive the reason the user made it.
344///
345/// What a grant covers is a set of keys derived from the call, not the tool
346/// name - see `leviath_cli::shell_keys`.
347#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
348#[serde(rename_all = "snake_case")]
349pub enum ApprovalScope {
350    /// Just this one call.
351    Once,
352    /// Every later call this covers, until the run leaves the current stage.
353    Stage,
354    /// Every later call this covers, for the rest of the run.
355    ///
356    /// Serialized as `session`, which is the name every client already sends:
357    /// `lev respond --session`, the REST `"scope": "session"`, and the ACP
358    /// `allow-always` option all mean this.
359    #[serde(rename = "session", alias = "run")]
360    Run,
361}
362
363/// A response written by the dashboard (or `lev respond`) to answer the worker.
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
365pub struct InteractionResponse {
366    /// Must match `InteractionRequest.id`.
367    pub request_id: String,
368    /// The user's free-text value (FreeText / InteractionPoints).
369    pub value: Option<String>,
370    /// Index into `options` for MultipleChoice / ToolApproval.
371    pub choice_index: Option<usize>,
372    /// Whether a ToolApproval was granted.
373    pub approved: Option<bool>,
374    /// Scope of a tool approval decision.
375    pub scope: Option<ApprovalScope>,
376}
377
378impl InteractionResponse {
379    /// Build a simple text response.
380    pub fn text(request_id: impl Into<String>, value: impl Into<String>) -> Self {
381        Self {
382            request_id: request_id.into(),
383            value: Some(value.into()),
384            choice_index: None,
385            approved: None,
386            scope: None,
387        }
388    }
389
390    /// Build a choice response (0-based index).
391    pub fn choice(request_id: impl Into<String>, index: usize) -> Self {
392        Self {
393            request_id: request_id.into(),
394            value: None,
395            choice_index: Some(index),
396            approved: None,
397            scope: None,
398        }
399    }
400
401    /// Build an approval response.
402    pub fn approval(request_id: impl Into<String>, approved: bool, scope: ApprovalScope) -> Self {
403        Self {
404            request_id: request_id.into(),
405            value: None,
406            choice_index: None,
407            approved: Some(approved),
408            scope: Some(scope),
409        }
410    }
411}
412
413// ─── Pure resolver helpers ────────────────────────────────────────────────────
414
415/// Resolve a `FreeText` response to a string.
416pub fn response_as_text(resp: &InteractionResponse) -> String {
417    resp.value.clone().unwrap_or_default()
418}
419
420/// Resolve a `MultipleChoice` response to the chosen option string.
421pub fn response_as_choice<'a>(
422    resp: &InteractionResponse,
423    options: &'a [String],
424) -> Option<&'a String> {
425    resp.choice_index.and_then(|i| options.get(i))
426}
427
428/// Returns `true` if a tool-approval response was granted.
429pub fn response_approved(resp: &InteractionResponse) -> bool {
430    resp.approved.unwrap_or(false)
431}
432
433/// Generate a simple monotonic ID from stage index + iteration.
434pub fn make_interaction_id(stage_idx: usize, iteration: usize) -> String {
435    format!("{}-{}", stage_idx, iteration)
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    /// "Allow tool call: `bash`?" asks whether to run a shell command without
443    /// saying which one - the only safe answer is no and the only practical one
444    /// is yes, so in practice everything gets approved unread.
445    #[test]
446    fn a_tool_approval_says_what_it_is_asking_about() {
447        let req = InteractionRequest::tool_approval(
448            "id",
449            "bash",
450            serde_json::json!({"command": "rm -rf build && make"}),
451            "implement",
452            &[],
453        );
454        assert!(
455            req.prompt.contains("rm -rf build && make"),
456            "{}",
457            req.prompt
458        );
459
460        // File tools name the path.
461        let req = InteractionRequest::tool_approval(
462            "id",
463            "write_file",
464            serde_json::json!({"path": "src/main.rs", "content": "..."}),
465            "implement",
466            &[],
467        );
468        assert!(req.prompt.contains("src/main.rs"), "{}", req.prompt);
469    }
470
471    /// A prompt is one line in a terminal. A heredoc that scrolls the decision
472    /// off screen is the same problem as showing nothing.
473    #[test]
474    fn a_long_or_multiline_command_is_summarised() {
475        let long = "echo ".to_string() + &"x".repeat(400);
476        let req = InteractionRequest::tool_approval(
477            "id",
478            "bash",
479            serde_json::json!({ "command": long }),
480            "s",
481            &[],
482        );
483        assert!(req.prompt.chars().count() < 200, "{}", req.prompt);
484        assert!(req.prompt.contains('…'), "{}", req.prompt);
485
486        let req = InteractionRequest::tool_approval(
487            "id",
488            "bash",
489            serde_json::json!({"command": "cat <<'EOF' > f\nline two\nEOF"}),
490            "s",
491            &[],
492        );
493        assert!(req.prompt.contains("cat <<'EOF' > f"), "{}", req.prompt);
494        assert!(!req.prompt.contains("line two"), "{}", req.prompt);
495        // The whole thing is still available to a richer UI.
496        assert!(req.tool_arguments.is_some());
497    }
498
499    /// A tool with no argument worth showing keeps the plain question rather
500    /// than gaining an empty pair of backticks.
501    #[test]
502    fn a_tool_without_a_telling_argument_reads_as_before() {
503        let req =
504            InteractionRequest::tool_approval("id", "list_dir", serde_json::json!({}), "s", &[]);
505        assert_eq!(req.prompt, "Allow tool call: `list_dir`?");
506        let req = InteractionRequest::tool_approval(
507            "id",
508            "bash",
509            serde_json::json!({"command": "   "}),
510            "s",
511            &[],
512        );
513        assert_eq!(req.prompt, "Allow tool call: `bash`?");
514        // Present but not a string: still nothing worth showing.
515        let req = InteractionRequest::tool_approval(
516            "id",
517            "bash",
518            serde_json::json!({"command": 42}),
519            "s",
520            &[],
521        );
522        assert_eq!(req.prompt, "Allow tool call: `bash`?");
523    }
524
525    /// "Allow for this session" said nothing about what the session would then
526    /// be allowed to do, and the dispatcher silently degraded it to once when
527    /// the call had no reusable key - so the user chose a grant they did not
528    /// get. The labels now name it.
529    #[test]
530    fn the_scope_options_name_what_they_grant() {
531        let keys = |names: &[&str]| -> Vec<String> {
532            names.iter().map(|n| format!("shell:{n}")).collect()
533        };
534        let req = InteractionRequest::tool_approval(
535            "id",
536            "shell",
537            serde_json::json!({"command": "ls && git status"}),
538            "s",
539            &keys(&["git status", "ls"]),
540        );
541        assert_eq!(req.options[1], "Allow git status, ls for this stage");
542        assert_eq!(req.options[2], "Allow git status, ls for this run");
543
544        // Beyond three, the rest are counted rather than wrapped off screen.
545        let req = InteractionRequest::tool_approval(
546            "id",
547            "shell",
548            serde_json::json!({}),
549            "s",
550            &keys(&["a", "b", "c", "d", "e"]),
551        );
552        assert_eq!(req.options[2], "Allow a, b, c +2 more for this run");
553
554        // A non-shell key is a bare tool name, with no prefix to strip.
555        let req = InteractionRequest::tool_approval(
556            "id",
557            "web_fetch",
558            serde_json::json!({}),
559            "s",
560            &["web_fetch".to_string()],
561        );
562        assert_eq!(req.options[2], "Allow web_fetch for this run");
563    }
564
565    /// A call with no reusable key says so, rather than offering a grant the
566    /// dispatcher will not record.
567    #[test]
568    fn an_unkeyable_call_says_it_will_ask_again() {
569        let req = InteractionRequest::tool_approval(
570            "id",
571            "shell",
572            serde_json::json!({"command": "echo `whoami`"}),
573            "s",
574            &[],
575        );
576        assert!(
577            req.options[1].contains("it will ask again"),
578            "{:?}",
579            req.options
580        );
581        assert!(
582            req.options[2].contains("it will ask again"),
583            "{:?}",
584            req.options
585        );
586    }
587
588    /// The index-to-scope mapping is what every client uses, so it has to match
589    /// the option order exactly, and an index it does not recognise must deny.
590    #[test]
591    fn approval_choice_matches_the_option_order() {
592        let req = InteractionRequest::tool_approval("id", "shell", serde_json::json!({}), "s", &[]);
593        assert_eq!(approval_choice(0), Some(ApprovalScope::Once));
594        assert!(req.options[1].contains("stage"));
595        assert_eq!(approval_choice(1), Some(ApprovalScope::Stage));
596        assert!(req.options[2].contains("run"));
597        assert_eq!(approval_choice(2), Some(ApprovalScope::Run));
598        assert_eq!(req.options[3], "Deny");
599        assert_eq!(approval_choice(3), None);
600        assert_eq!(
601            approval_choice(99),
602            None,
603            "an unknown answer must not approve"
604        );
605    }
606
607    /// A gate approval is a different decision, so it keeps its own wording and
608    /// offers no per-stage scope: clearance is not keyed on what a call runs.
609    #[test]
610    fn a_gate_approval_offers_run_scope_and_no_stage_scope() {
611        let req =
612            InteractionRequest::gate_approval("g", "web_fetch", serde_json::json!({}), "research");
613        assert_eq!(req.kind, InteractionKind::ToolApproval);
614        assert_eq!(req.prompt, "Allow tool call: `web_fetch`?");
615        assert_eq!(req.options, ["Allow once", "Allow for this run", "Deny"]);
616    }
617
618    /// `session` stays the wire name for run scope: every client already sends
619    /// it, and renaming it on the wire would silently narrow their grants.
620    #[test]
621    fn run_scope_serialises_as_session() {
622        assert_eq!(
623            serde_json::to_string(&ApprovalScope::Run).unwrap(),
624            "\"session\""
625        );
626        for wire in ["\"session\"", "\"run\""] {
627            let back: ApprovalScope = serde_json::from_str(wire).unwrap();
628            assert_eq!(back, ApprovalScope::Run, "{wire}");
629        }
630        assert_eq!(
631            serde_json::to_string(&ApprovalScope::Stage).unwrap(),
632            "\"stage\""
633        );
634    }
635
636    // ─── InteractionRequest / InteractionResponse constructors ─────────────
637
638    #[test]
639    fn test_request_builders() {
640        let r = InteractionRequest::free_text("id1", "What now?", "plan", true);
641        assert_eq!(r.kind, InteractionKind::FreeText);
642        assert!(r.required);
643
644        let r = InteractionRequest::multiple_choice(
645            "id2",
646            "Pick one",
647            vec!["A".into(), "B".into()],
648            "plan",
649        );
650        assert_eq!(r.kind, InteractionKind::MultipleChoice);
651        assert_eq!(r.options.len(), 2);
652
653        let r = InteractionRequest::tool_approval(
654            "id3",
655            "bash",
656            serde_json::json!({"cmd": "ls"}),
657            "impl",
658            &[],
659        );
660        assert_eq!(r.kind, InteractionKind::ToolApproval);
661        assert_eq!(r.options.len(), 4);
662    }
663
664    #[test]
665    fn test_edit_text_request_builder_seeds_body() {
666        let r = InteractionRequest::edit_text("id4", "Edit this", "plan", "current text");
667        assert_eq!(r.kind, InteractionKind::EditText);
668        assert!(r.required);
669        assert_eq!(r.body.as_deref(), Some("current text"));
670        assert_eq!(r.prompt, "Edit this");
671    }
672
673    #[test]
674    fn test_edit_text_kind_serde_roundtrip_snake_case() {
675        let r = InteractionRequest::edit_text("id5", "p", "plan", "seed");
676        let json = serde_json::to_string(&r).unwrap();
677        // snake_case rename ⇒ "edit_text"
678        assert!(json.contains("\"edit_text\""));
679        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
680        assert_eq!(back.kind, InteractionKind::EditText);
681        assert_eq!(back.body.as_deref(), Some("seed"));
682    }
683
684    #[test]
685    fn test_response_builders() {
686        let r = InteractionResponse::text("id1", "hello");
687        assert_eq!(r.value.as_deref(), Some("hello"));
688
689        let r = InteractionResponse::choice("id2", 1);
690        assert_eq!(r.choice_index, Some(1));
691
692        let r = InteractionResponse::approval("id3", true, ApprovalScope::Run);
693        assert_eq!(r.approved, Some(true));
694        assert_eq!(r.scope, Some(ApprovalScope::Run));
695    }
696
697    #[test]
698    fn test_response_as_text() {
699        let r = InteractionResponse::text("id", "answer");
700        assert_eq!(response_as_text(&r), "answer");
701        let empty = InteractionResponse {
702            request_id: "x".into(),
703            value: None,
704            choice_index: None,
705            approved: None,
706            scope: None,
707        };
708        assert_eq!(response_as_text(&empty), "");
709    }
710
711    #[test]
712    fn test_response_as_choice() {
713        let opts = vec!["Alpha".to_string(), "Beta".to_string()];
714        let r = InteractionResponse::choice("id", 0);
715        assert_eq!(response_as_choice(&r, &opts), Some(&"Alpha".to_string()));
716        let r = InteractionResponse::choice("id", 1);
717        assert_eq!(response_as_choice(&r, &opts), Some(&"Beta".to_string()));
718        let r = InteractionResponse::choice("id", 99);
719        assert!(response_as_choice(&r, &opts).is_none());
720    }
721
722    #[test]
723    fn test_make_interaction_id() {
724        let id = make_interaction_id(2, 5);
725        assert_eq!(id, "2-5");
726    }
727
728    #[test]
729    fn test_free_text_request_not_required() {
730        let r = InteractionRequest::free_text("ft1", "optional?", "stage1", false);
731        assert_eq!(r.kind, InteractionKind::FreeText);
732        assert!(!r.required);
733        assert_eq!(r.id, "ft1");
734        assert_eq!(r.prompt, "optional?");
735        assert_eq!(r.stage_name, "stage1");
736        assert!(r.options.is_empty());
737        assert!(r.tool_name.is_none());
738        assert!(r.tool_arguments.is_none());
739        assert!(r.body.is_none());
740        assert_eq!(r.body_format, BodyFormat::Plain);
741    }
742
743    #[test]
744    fn test_review_request() {
745        let r = InteractionRequest::review("rev1", "Review Title", "# Markdown body", "plan");
746        assert_eq!(r.kind, InteractionKind::FreeText);
747        assert!(r.required);
748        assert_eq!(r.prompt, "Review Title");
749        assert_eq!(r.body.as_deref(), Some("# Markdown body"));
750        assert_eq!(r.body_format, BodyFormat::Markdown);
751        assert_eq!(r.stage_name, "plan");
752    }
753
754    #[test]
755    fn test_confirm_request() {
756        let r = InteractionRequest::confirm("c1", "Proceed?", "deploy");
757        assert_eq!(r.kind, InteractionKind::Confirm);
758        assert_eq!(r.options, vec!["Yes", "No"]);
759        assert!(r.required);
760        assert_eq!(r.stage_name, "deploy");
761    }
762
763    #[test]
764    fn test_tool_approval_request() {
765        let args = serde_json::json!({"file": "test.txt"});
766        let r = InteractionRequest::tool_approval("ta1", "write_file", args, "code", &[]);
767        assert_eq!(r.kind, InteractionKind::ToolApproval);
768        assert_eq!(r.tool_name.as_deref(), Some("write_file"));
769        assert!(r.tool_arguments.is_some());
770        assert_eq!(r.options.len(), 4);
771        assert!(r.prompt.contains("write_file"));
772    }
773
774    #[test]
775    fn test_response_text_empty() {
776        let r = InteractionResponse::text("id", "");
777        assert_eq!(r.value.as_deref(), Some(""));
778        assert!(r.choice_index.is_none());
779        assert!(r.approved.is_none());
780        assert!(r.scope.is_none());
781    }
782
783    #[test]
784    fn test_response_approval_denied() {
785        let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
786        assert_eq!(r.approved, Some(false));
787        assert_eq!(r.scope, Some(ApprovalScope::Once));
788    }
789
790    #[test]
791    fn test_response_approved_true() {
792        let r = InteractionResponse::approval("id", true, ApprovalScope::Run);
793        assert!(response_approved(&r));
794    }
795
796    #[test]
797    fn test_response_approved_false() {
798        let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
799        assert!(!response_approved(&r));
800    }
801
802    #[test]
803    fn test_response_approved_none() {
804        let r = InteractionResponse::text("id", "hello");
805        assert!(!response_approved(&r));
806    }
807
808    #[test]
809    fn test_approval_scope_serde_roundtrip() {
810        for scope in [ApprovalScope::Once, ApprovalScope::Run] {
811            let json = serde_json::to_string(&scope).unwrap();
812            let back: ApprovalScope = serde_json::from_str(&json).unwrap();
813            assert_eq!(scope, back);
814        }
815    }
816
817    #[test]
818    fn test_approval_scope_snake_case() {
819        let json = serde_json::to_string(&ApprovalScope::Once).unwrap();
820        assert_eq!(json, "\"once\"");
821        let json = serde_json::to_string(&ApprovalScope::Run).unwrap();
822        assert_eq!(json, "\"session\"");
823    }
824
825    #[test]
826    fn test_interaction_kind_serde_roundtrip() {
827        for kind in [
828            InteractionKind::FreeText,
829            InteractionKind::MultipleChoice,
830            InteractionKind::Confirm,
831            InteractionKind::ToolApproval,
832        ] {
833            let json = serde_json::to_string(&kind).unwrap();
834            let back: InteractionKind = serde_json::from_str(&json).unwrap();
835            assert_eq!(kind, back);
836        }
837    }
838
839    #[test]
840    fn test_body_format_serde_roundtrip() {
841        for fmt in [BodyFormat::Plain, BodyFormat::Markdown] {
842            let json = serde_json::to_string(&fmt).unwrap();
843            let back: BodyFormat = serde_json::from_str(&json).unwrap();
844            assert_eq!(fmt, back);
845        }
846    }
847
848    #[test]
849    fn test_body_format_default_is_plain() {
850        let fmt = BodyFormat::default();
851        assert_eq!(fmt, BodyFormat::Plain);
852    }
853
854    #[test]
855    fn test_interaction_request_serde_roundtrip() {
856        let req = InteractionRequest::tool_approval(
857            "serde1",
858            "bash",
859            serde_json::json!({"cmd": "ls -la"}),
860            "code",
861            &[],
862        );
863        let json = serde_json::to_string(&req).unwrap();
864        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
865        assert_eq!(back.id, "serde1");
866        assert_eq!(back.kind, InteractionKind::ToolApproval);
867        assert_eq!(back.tool_name.as_deref(), Some("bash"));
868    }
869
870    #[test]
871    fn test_interaction_response_serde_roundtrip() {
872        let resp = InteractionResponse::approval("serde2", true, ApprovalScope::Run);
873        let json = serde_json::to_string(&resp).unwrap();
874        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
875        assert_eq!(back.request_id, "serde2");
876        assert_eq!(back.approved, Some(true));
877        assert_eq!(back.scope, Some(ApprovalScope::Run));
878    }
879
880    #[test]
881    fn test_make_interaction_id_zero() {
882        assert_eq!(make_interaction_id(0, 0), "0-0");
883    }
884
885    #[test]
886    fn test_make_interaction_id_large() {
887        assert_eq!(make_interaction_id(999, 1000), "999-1000");
888    }
889
890    #[test]
891    fn test_response_as_choice_no_choice_index() {
892        let opts = vec!["A".to_string(), "B".to_string()];
893        let r = InteractionResponse::text("id", "hello");
894        assert!(response_as_choice(&r, &opts).is_none());
895    }
896
897    #[test]
898    fn test_response_as_choice_empty_options() {
899        let opts: Vec<String> = vec![];
900        let r = InteractionResponse::choice("id", 0);
901        assert!(response_as_choice(&r, &opts).is_none());
902    }
903
904    #[test]
905    fn test_free_text_request_defaults() {
906        let r = InteractionRequest::free_text("ft", "prompt", "stage", true);
907        assert!(r.body.is_none());
908        assert_eq!(r.body_format, BodyFormat::Plain);
909        assert!(r.tool_name.is_none());
910        assert!(r.tool_arguments.is_none());
911        assert!(r.options.is_empty());
912    }
913
914    #[test]
915    fn test_multiple_choice_request_is_required() {
916        let r = InteractionRequest::multiple_choice("mc", "Pick", vec!["A".into()], "stage");
917        assert!(r.required);
918    }
919
920    #[test]
921    fn test_confirm_request_is_required() {
922        let r = InteractionRequest::confirm("c", "Sure?", "stage");
923        assert!(r.required);
924    }
925
926    #[test]
927    fn test_tool_approval_is_required() {
928        let r =
929            InteractionRequest::tool_approval("ta", "bash", serde_json::json!({}), "stage", &[]);
930        assert!(r.required);
931    }
932
933    #[test]
934    fn test_interaction_kind_snake_case_values() {
935        assert_eq!(
936            serde_json::to_string(&InteractionKind::FreeText).unwrap(),
937            "\"free_text\""
938        );
939        assert_eq!(
940            serde_json::to_string(&InteractionKind::MultipleChoice).unwrap(),
941            "\"multiple_choice\""
942        );
943        assert_eq!(
944            serde_json::to_string(&InteractionKind::ToolApproval).unwrap(),
945            "\"tool_approval\""
946        );
947        assert_eq!(
948            serde_json::to_string(&InteractionKind::Confirm).unwrap(),
949            "\"confirm\""
950        );
951    }
952
953    #[test]
954    fn test_body_format_snake_case_values() {
955        assert_eq!(
956            serde_json::to_string(&BodyFormat::Plain).unwrap(),
957            "\"plain\""
958        );
959        assert_eq!(
960            serde_json::to_string(&BodyFormat::Markdown).unwrap(),
961            "\"markdown\""
962        );
963    }
964
965    #[test]
966    fn test_request_free_text_serde_roundtrip() {
967        let req = InteractionRequest::free_text("ft1", "What?", "main", false);
968        let json = serde_json::to_string(&req).unwrap();
969        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
970        assert_eq!(back.id, "ft1");
971        assert_eq!(back.kind, InteractionKind::FreeText);
972        assert!(!back.required);
973        assert_eq!(back.stage_name, "main");
974    }
975
976    #[test]
977    fn test_request_multiple_choice_serde_roundtrip() {
978        let req = InteractionRequest::multiple_choice(
979            "mc1",
980            "Choose",
981            vec!["A".into(), "B".into(), "C".into()],
982            "plan",
983        );
984        let json = serde_json::to_string(&req).unwrap();
985        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
986        assert_eq!(back.kind, InteractionKind::MultipleChoice);
987        assert_eq!(back.options.len(), 3);
988        assert_eq!(back.options[2], "C");
989    }
990
991    #[test]
992    fn test_request_confirm_serde_roundtrip() {
993        let req = InteractionRequest::confirm("c1", "Proceed?", "deploy");
994        let json = serde_json::to_string(&req).unwrap();
995        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
996        assert_eq!(back.kind, InteractionKind::Confirm);
997        assert_eq!(back.options, vec!["Yes", "No"]);
998    }
999
1000    #[test]
1001    fn test_request_review_serde_roundtrip() {
1002        let req = InteractionRequest::review("rev1", "Title", "# Body\ntext", "review");
1003        let json = serde_json::to_string(&req).unwrap();
1004        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
1005        assert_eq!(back.body_format, BodyFormat::Markdown);
1006        assert_eq!(back.body.as_deref(), Some("# Body\ntext"));
1007    }
1008
1009    #[test]
1010    fn test_response_text_serde_roundtrip() {
1011        let resp = InteractionResponse::text("t1", "my answer");
1012        let json = serde_json::to_string(&resp).unwrap();
1013        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1014        assert_eq!(back.request_id, "t1");
1015        assert_eq!(back.value.as_deref(), Some("my answer"));
1016        assert!(back.choice_index.is_none());
1017        assert!(back.approved.is_none());
1018        assert!(back.scope.is_none());
1019    }
1020
1021    #[test]
1022    fn test_response_choice_serde_roundtrip() {
1023        let resp = InteractionResponse::choice("c1", 2);
1024        let json = serde_json::to_string(&resp).unwrap();
1025        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1026        assert_eq!(back.choice_index, Some(2));
1027        assert!(back.value.is_none());
1028    }
1029
1030    #[test]
1031    fn test_response_approval_serde_roundtrip() {
1032        let resp = InteractionResponse::approval("a1", false, ApprovalScope::Run);
1033        let json = serde_json::to_string(&resp).unwrap();
1034        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
1035        assert_eq!(back.approved, Some(false));
1036        assert_eq!(back.scope, Some(ApprovalScope::Run));
1037    }
1038
1039    #[test]
1040    fn test_response_as_text_with_value() {
1041        let r = InteractionResponse::text("id", "some text value");
1042        assert_eq!(response_as_text(&r), "some text value");
1043    }
1044
1045    #[test]
1046    fn test_response_approved_session_scope() {
1047        let r = InteractionResponse::approval("id", true, ApprovalScope::Run);
1048        assert!(response_approved(&r));
1049        assert_eq!(r.scope, Some(ApprovalScope::Run));
1050    }
1051
1052    #[test]
1053    fn test_make_interaction_id_various() {
1054        assert_eq!(make_interaction_id(1, 2), "1-2");
1055        assert_eq!(make_interaction_id(10, 20), "10-20");
1056    }
1057
1058    #[test]
1059    fn test_default_true_via_serde_missing_required_field() {
1060        // JSON without `required` - should default to true via default_true()
1061        let json = r#"{
1062            "id": "dt1",
1063            "kind": "free_text",
1064            "prompt": "test prompt",
1065            "stage_name": "stage"
1066        }"#;
1067        let req: InteractionRequest = serde_json::from_str(json).unwrap();
1068        assert!(req.required);
1069    }
1070}