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    pub fn tool_approval(
214        id: impl Into<String>,
215        tool_name: impl Into<String>,
216        arguments: serde_json::Value,
217        stage: impl Into<String>,
218    ) -> Self {
219        let tool = tool_name.into();
220        let prompt = match Self::approval_detail(&tool, &arguments) {
221            Some(detail) => format!("Allow tool call: `{tool}` - {detail}?"),
222            None => format!("Allow tool call: `{tool}`?"),
223        };
224        Self {
225            id: id.into(),
226            kind: InteractionKind::ToolApproval,
227            prompt,
228            options: vec![
229                "Allow once".to_string(),
230                "Allow for this session".to_string(),
231                "Deny".to_string(),
232            ],
233            tool_name: Some(tool),
234            tool_arguments: Some(arguments),
235            required: true,
236            stage_name: stage.into(),
237            body: None,
238            body_format: BodyFormat::Plain,
239        }
240    }
241}
242
243// ─── Response ───────────────────────────────────────────────────────────────
244
245/// The scope of an approval decision.
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247#[serde(rename_all = "snake_case")]
248pub enum ApprovalScope {
249    /// Allow/deny just this one call.
250    Once,
251    /// Allow/deny all calls to this tool for the rest of this agent run.
252    Session,
253}
254
255/// A response written by the dashboard (or `lev respond`) to answer the worker.
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
257pub struct InteractionResponse {
258    /// Must match `InteractionRequest.id`.
259    pub request_id: String,
260    /// The user's free-text value (FreeText / InteractionPoints).
261    pub value: Option<String>,
262    /// Index into `options` for MultipleChoice / ToolApproval.
263    pub choice_index: Option<usize>,
264    /// Whether a ToolApproval was granted.
265    pub approved: Option<bool>,
266    /// Scope of a tool approval decision.
267    pub scope: Option<ApprovalScope>,
268}
269
270impl InteractionResponse {
271    /// Build a simple text response.
272    pub fn text(request_id: impl Into<String>, value: impl Into<String>) -> Self {
273        Self {
274            request_id: request_id.into(),
275            value: Some(value.into()),
276            choice_index: None,
277            approved: None,
278            scope: None,
279        }
280    }
281
282    /// Build a choice response (0-based index).
283    pub fn choice(request_id: impl Into<String>, index: usize) -> Self {
284        Self {
285            request_id: request_id.into(),
286            value: None,
287            choice_index: Some(index),
288            approved: None,
289            scope: None,
290        }
291    }
292
293    /// Build an approval response.
294    pub fn approval(request_id: impl Into<String>, approved: bool, scope: ApprovalScope) -> Self {
295        Self {
296            request_id: request_id.into(),
297            value: None,
298            choice_index: None,
299            approved: Some(approved),
300            scope: Some(scope),
301        }
302    }
303}
304
305// ─── Pure resolver helpers ────────────────────────────────────────────────────
306
307/// Resolve a `FreeText` response to a string.
308pub fn response_as_text(resp: &InteractionResponse) -> String {
309    resp.value.clone().unwrap_or_default()
310}
311
312/// Resolve a `MultipleChoice` response to the chosen option string.
313pub fn response_as_choice<'a>(
314    resp: &InteractionResponse,
315    options: &'a [String],
316) -> Option<&'a String> {
317    resp.choice_index.and_then(|i| options.get(i))
318}
319
320/// Returns `true` if a tool-approval response was granted.
321pub fn response_approved(resp: &InteractionResponse) -> bool {
322    resp.approved.unwrap_or(false)
323}
324
325/// Generate a simple monotonic ID from stage index + iteration.
326pub fn make_interaction_id(stage_idx: usize, iteration: usize) -> String {
327    format!("{}-{}", stage_idx, iteration)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    /// "Allow tool call: `bash`?" asks whether to run a shell command without
335    /// saying which one - the only safe answer is no and the only practical one
336    /// is yes, so in practice everything gets approved unread.
337    #[test]
338    fn a_tool_approval_says_what_it_is_asking_about() {
339        let req = InteractionRequest::tool_approval(
340            "id",
341            "bash",
342            serde_json::json!({"command": "rm -rf build && make"}),
343            "implement",
344        );
345        assert!(
346            req.prompt.contains("rm -rf build && make"),
347            "{}",
348            req.prompt
349        );
350
351        // File tools name the path.
352        let req = InteractionRequest::tool_approval(
353            "id",
354            "write_file",
355            serde_json::json!({"path": "src/main.rs", "content": "..."}),
356            "implement",
357        );
358        assert!(req.prompt.contains("src/main.rs"), "{}", req.prompt);
359    }
360
361    /// A prompt is one line in a terminal. A heredoc that scrolls the decision
362    /// off screen is the same problem as showing nothing.
363    #[test]
364    fn a_long_or_multiline_command_is_summarised() {
365        let long = "echo ".to_string() + &"x".repeat(400);
366        let req = InteractionRequest::tool_approval(
367            "id",
368            "bash",
369            serde_json::json!({ "command": long }),
370            "s",
371        );
372        assert!(req.prompt.chars().count() < 200, "{}", req.prompt);
373        assert!(req.prompt.contains('…'), "{}", req.prompt);
374
375        let req = InteractionRequest::tool_approval(
376            "id",
377            "bash",
378            serde_json::json!({"command": "cat <<'EOF' > f\nline two\nEOF"}),
379            "s",
380        );
381        assert!(req.prompt.contains("cat <<'EOF' > f"), "{}", req.prompt);
382        assert!(!req.prompt.contains("line two"), "{}", req.prompt);
383        // The whole thing is still available to a richer UI.
384        assert!(req.tool_arguments.is_some());
385    }
386
387    /// A tool with no argument worth showing keeps the plain question rather
388    /// than gaining an empty pair of backticks.
389    #[test]
390    fn a_tool_without_a_telling_argument_reads_as_before() {
391        let req = InteractionRequest::tool_approval("id", "list_dir", serde_json::json!({}), "s");
392        assert_eq!(req.prompt, "Allow tool call: `list_dir`?");
393        let req = InteractionRequest::tool_approval(
394            "id",
395            "bash",
396            serde_json::json!({"command": "   "}),
397            "s",
398        );
399        assert_eq!(req.prompt, "Allow tool call: `bash`?");
400        // Present but not a string: still nothing worth showing.
401        let req = InteractionRequest::tool_approval(
402            "id",
403            "bash",
404            serde_json::json!({"command": 42}),
405            "s",
406        );
407        assert_eq!(req.prompt, "Allow tool call: `bash`?");
408    }
409
410    // ─── InteractionRequest / InteractionResponse constructors ─────────────
411
412    #[test]
413    fn test_request_builders() {
414        let r = InteractionRequest::free_text("id1", "What now?", "plan", true);
415        assert_eq!(r.kind, InteractionKind::FreeText);
416        assert!(r.required);
417
418        let r = InteractionRequest::multiple_choice(
419            "id2",
420            "Pick one",
421            vec!["A".into(), "B".into()],
422            "plan",
423        );
424        assert_eq!(r.kind, InteractionKind::MultipleChoice);
425        assert_eq!(r.options.len(), 2);
426
427        let r = InteractionRequest::tool_approval(
428            "id3",
429            "bash",
430            serde_json::json!({"cmd": "ls"}),
431            "impl",
432        );
433        assert_eq!(r.kind, InteractionKind::ToolApproval);
434        assert_eq!(r.options.len(), 3);
435    }
436
437    #[test]
438    fn test_edit_text_request_builder_seeds_body() {
439        let r = InteractionRequest::edit_text("id4", "Edit this", "plan", "current text");
440        assert_eq!(r.kind, InteractionKind::EditText);
441        assert!(r.required);
442        assert_eq!(r.body.as_deref(), Some("current text"));
443        assert_eq!(r.prompt, "Edit this");
444    }
445
446    #[test]
447    fn test_edit_text_kind_serde_roundtrip_snake_case() {
448        let r = InteractionRequest::edit_text("id5", "p", "plan", "seed");
449        let json = serde_json::to_string(&r).unwrap();
450        // snake_case rename ⇒ "edit_text"
451        assert!(json.contains("\"edit_text\""));
452        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
453        assert_eq!(back.kind, InteractionKind::EditText);
454        assert_eq!(back.body.as_deref(), Some("seed"));
455    }
456
457    #[test]
458    fn test_response_builders() {
459        let r = InteractionResponse::text("id1", "hello");
460        assert_eq!(r.value.as_deref(), Some("hello"));
461
462        let r = InteractionResponse::choice("id2", 1);
463        assert_eq!(r.choice_index, Some(1));
464
465        let r = InteractionResponse::approval("id3", true, ApprovalScope::Session);
466        assert_eq!(r.approved, Some(true));
467        assert_eq!(r.scope, Some(ApprovalScope::Session));
468    }
469
470    #[test]
471    fn test_response_as_text() {
472        let r = InteractionResponse::text("id", "answer");
473        assert_eq!(response_as_text(&r), "answer");
474        let empty = InteractionResponse {
475            request_id: "x".into(),
476            value: None,
477            choice_index: None,
478            approved: None,
479            scope: None,
480        };
481        assert_eq!(response_as_text(&empty), "");
482    }
483
484    #[test]
485    fn test_response_as_choice() {
486        let opts = vec!["Alpha".to_string(), "Beta".to_string()];
487        let r = InteractionResponse::choice("id", 0);
488        assert_eq!(response_as_choice(&r, &opts), Some(&"Alpha".to_string()));
489        let r = InteractionResponse::choice("id", 1);
490        assert_eq!(response_as_choice(&r, &opts), Some(&"Beta".to_string()));
491        let r = InteractionResponse::choice("id", 99);
492        assert!(response_as_choice(&r, &opts).is_none());
493    }
494
495    #[test]
496    fn test_make_interaction_id() {
497        let id = make_interaction_id(2, 5);
498        assert_eq!(id, "2-5");
499    }
500
501    #[test]
502    fn test_free_text_request_not_required() {
503        let r = InteractionRequest::free_text("ft1", "optional?", "stage1", false);
504        assert_eq!(r.kind, InteractionKind::FreeText);
505        assert!(!r.required);
506        assert_eq!(r.id, "ft1");
507        assert_eq!(r.prompt, "optional?");
508        assert_eq!(r.stage_name, "stage1");
509        assert!(r.options.is_empty());
510        assert!(r.tool_name.is_none());
511        assert!(r.tool_arguments.is_none());
512        assert!(r.body.is_none());
513        assert_eq!(r.body_format, BodyFormat::Plain);
514    }
515
516    #[test]
517    fn test_review_request() {
518        let r = InteractionRequest::review("rev1", "Review Title", "# Markdown body", "plan");
519        assert_eq!(r.kind, InteractionKind::FreeText);
520        assert!(r.required);
521        assert_eq!(r.prompt, "Review Title");
522        assert_eq!(r.body.as_deref(), Some("# Markdown body"));
523        assert_eq!(r.body_format, BodyFormat::Markdown);
524        assert_eq!(r.stage_name, "plan");
525    }
526
527    #[test]
528    fn test_confirm_request() {
529        let r = InteractionRequest::confirm("c1", "Proceed?", "deploy");
530        assert_eq!(r.kind, InteractionKind::Confirm);
531        assert_eq!(r.options, vec!["Yes", "No"]);
532        assert!(r.required);
533        assert_eq!(r.stage_name, "deploy");
534    }
535
536    #[test]
537    fn test_tool_approval_request() {
538        let args = serde_json::json!({"file": "test.txt"});
539        let r = InteractionRequest::tool_approval("ta1", "write_file", args, "code");
540        assert_eq!(r.kind, InteractionKind::ToolApproval);
541        assert_eq!(r.tool_name.as_deref(), Some("write_file"));
542        assert!(r.tool_arguments.is_some());
543        assert_eq!(r.options.len(), 3);
544        assert!(r.prompt.contains("write_file"));
545    }
546
547    #[test]
548    fn test_response_text_empty() {
549        let r = InteractionResponse::text("id", "");
550        assert_eq!(r.value.as_deref(), Some(""));
551        assert!(r.choice_index.is_none());
552        assert!(r.approved.is_none());
553        assert!(r.scope.is_none());
554    }
555
556    #[test]
557    fn test_response_approval_denied() {
558        let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
559        assert_eq!(r.approved, Some(false));
560        assert_eq!(r.scope, Some(ApprovalScope::Once));
561    }
562
563    #[test]
564    fn test_response_approved_true() {
565        let r = InteractionResponse::approval("id", true, ApprovalScope::Session);
566        assert!(response_approved(&r));
567    }
568
569    #[test]
570    fn test_response_approved_false() {
571        let r = InteractionResponse::approval("id", false, ApprovalScope::Once);
572        assert!(!response_approved(&r));
573    }
574
575    #[test]
576    fn test_response_approved_none() {
577        let r = InteractionResponse::text("id", "hello");
578        assert!(!response_approved(&r));
579    }
580
581    #[test]
582    fn test_approval_scope_serde_roundtrip() {
583        for scope in [ApprovalScope::Once, ApprovalScope::Session] {
584            let json = serde_json::to_string(&scope).unwrap();
585            let back: ApprovalScope = serde_json::from_str(&json).unwrap();
586            assert_eq!(scope, back);
587        }
588    }
589
590    #[test]
591    fn test_approval_scope_snake_case() {
592        let json = serde_json::to_string(&ApprovalScope::Once).unwrap();
593        assert_eq!(json, "\"once\"");
594        let json = serde_json::to_string(&ApprovalScope::Session).unwrap();
595        assert_eq!(json, "\"session\"");
596    }
597
598    #[test]
599    fn test_interaction_kind_serde_roundtrip() {
600        for kind in [
601            InteractionKind::FreeText,
602            InteractionKind::MultipleChoice,
603            InteractionKind::Confirm,
604            InteractionKind::ToolApproval,
605        ] {
606            let json = serde_json::to_string(&kind).unwrap();
607            let back: InteractionKind = serde_json::from_str(&json).unwrap();
608            assert_eq!(kind, back);
609        }
610    }
611
612    #[test]
613    fn test_body_format_serde_roundtrip() {
614        for fmt in [BodyFormat::Plain, BodyFormat::Markdown] {
615            let json = serde_json::to_string(&fmt).unwrap();
616            let back: BodyFormat = serde_json::from_str(&json).unwrap();
617            assert_eq!(fmt, back);
618        }
619    }
620
621    #[test]
622    fn test_body_format_default_is_plain() {
623        let fmt = BodyFormat::default();
624        assert_eq!(fmt, BodyFormat::Plain);
625    }
626
627    #[test]
628    fn test_interaction_request_serde_roundtrip() {
629        let req = InteractionRequest::tool_approval(
630            "serde1",
631            "bash",
632            serde_json::json!({"cmd": "ls -la"}),
633            "code",
634        );
635        let json = serde_json::to_string(&req).unwrap();
636        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
637        assert_eq!(back.id, "serde1");
638        assert_eq!(back.kind, InteractionKind::ToolApproval);
639        assert_eq!(back.tool_name.as_deref(), Some("bash"));
640    }
641
642    #[test]
643    fn test_interaction_response_serde_roundtrip() {
644        let resp = InteractionResponse::approval("serde2", true, ApprovalScope::Session);
645        let json = serde_json::to_string(&resp).unwrap();
646        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
647        assert_eq!(back.request_id, "serde2");
648        assert_eq!(back.approved, Some(true));
649        assert_eq!(back.scope, Some(ApprovalScope::Session));
650    }
651
652    #[test]
653    fn test_make_interaction_id_zero() {
654        assert_eq!(make_interaction_id(0, 0), "0-0");
655    }
656
657    #[test]
658    fn test_make_interaction_id_large() {
659        assert_eq!(make_interaction_id(999, 1000), "999-1000");
660    }
661
662    #[test]
663    fn test_response_as_choice_no_choice_index() {
664        let opts = vec!["A".to_string(), "B".to_string()];
665        let r = InteractionResponse::text("id", "hello");
666        assert!(response_as_choice(&r, &opts).is_none());
667    }
668
669    #[test]
670    fn test_response_as_choice_empty_options() {
671        let opts: Vec<String> = vec![];
672        let r = InteractionResponse::choice("id", 0);
673        assert!(response_as_choice(&r, &opts).is_none());
674    }
675
676    #[test]
677    fn test_free_text_request_defaults() {
678        let r = InteractionRequest::free_text("ft", "prompt", "stage", true);
679        assert!(r.body.is_none());
680        assert_eq!(r.body_format, BodyFormat::Plain);
681        assert!(r.tool_name.is_none());
682        assert!(r.tool_arguments.is_none());
683        assert!(r.options.is_empty());
684    }
685
686    #[test]
687    fn test_multiple_choice_request_is_required() {
688        let r = InteractionRequest::multiple_choice("mc", "Pick", vec!["A".into()], "stage");
689        assert!(r.required);
690    }
691
692    #[test]
693    fn test_confirm_request_is_required() {
694        let r = InteractionRequest::confirm("c", "Sure?", "stage");
695        assert!(r.required);
696    }
697
698    #[test]
699    fn test_tool_approval_is_required() {
700        let r = InteractionRequest::tool_approval("ta", "bash", serde_json::json!({}), "stage");
701        assert!(r.required);
702    }
703
704    #[test]
705    fn test_interaction_kind_snake_case_values() {
706        assert_eq!(
707            serde_json::to_string(&InteractionKind::FreeText).unwrap(),
708            "\"free_text\""
709        );
710        assert_eq!(
711            serde_json::to_string(&InteractionKind::MultipleChoice).unwrap(),
712            "\"multiple_choice\""
713        );
714        assert_eq!(
715            serde_json::to_string(&InteractionKind::ToolApproval).unwrap(),
716            "\"tool_approval\""
717        );
718        assert_eq!(
719            serde_json::to_string(&InteractionKind::Confirm).unwrap(),
720            "\"confirm\""
721        );
722    }
723
724    #[test]
725    fn test_body_format_snake_case_values() {
726        assert_eq!(
727            serde_json::to_string(&BodyFormat::Plain).unwrap(),
728            "\"plain\""
729        );
730        assert_eq!(
731            serde_json::to_string(&BodyFormat::Markdown).unwrap(),
732            "\"markdown\""
733        );
734    }
735
736    #[test]
737    fn test_request_free_text_serde_roundtrip() {
738        let req = InteractionRequest::free_text("ft1", "What?", "main", false);
739        let json = serde_json::to_string(&req).unwrap();
740        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
741        assert_eq!(back.id, "ft1");
742        assert_eq!(back.kind, InteractionKind::FreeText);
743        assert!(!back.required);
744        assert_eq!(back.stage_name, "main");
745    }
746
747    #[test]
748    fn test_request_multiple_choice_serde_roundtrip() {
749        let req = InteractionRequest::multiple_choice(
750            "mc1",
751            "Choose",
752            vec!["A".into(), "B".into(), "C".into()],
753            "plan",
754        );
755        let json = serde_json::to_string(&req).unwrap();
756        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
757        assert_eq!(back.kind, InteractionKind::MultipleChoice);
758        assert_eq!(back.options.len(), 3);
759        assert_eq!(back.options[2], "C");
760    }
761
762    #[test]
763    fn test_request_confirm_serde_roundtrip() {
764        let req = InteractionRequest::confirm("c1", "Proceed?", "deploy");
765        let json = serde_json::to_string(&req).unwrap();
766        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
767        assert_eq!(back.kind, InteractionKind::Confirm);
768        assert_eq!(back.options, vec!["Yes", "No"]);
769    }
770
771    #[test]
772    fn test_request_review_serde_roundtrip() {
773        let req = InteractionRequest::review("rev1", "Title", "# Body\ntext", "review");
774        let json = serde_json::to_string(&req).unwrap();
775        let back: InteractionRequest = serde_json::from_str(&json).unwrap();
776        assert_eq!(back.body_format, BodyFormat::Markdown);
777        assert_eq!(back.body.as_deref(), Some("# Body\ntext"));
778    }
779
780    #[test]
781    fn test_response_text_serde_roundtrip() {
782        let resp = InteractionResponse::text("t1", "my answer");
783        let json = serde_json::to_string(&resp).unwrap();
784        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
785        assert_eq!(back.request_id, "t1");
786        assert_eq!(back.value.as_deref(), Some("my answer"));
787        assert!(back.choice_index.is_none());
788        assert!(back.approved.is_none());
789        assert!(back.scope.is_none());
790    }
791
792    #[test]
793    fn test_response_choice_serde_roundtrip() {
794        let resp = InteractionResponse::choice("c1", 2);
795        let json = serde_json::to_string(&resp).unwrap();
796        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
797        assert_eq!(back.choice_index, Some(2));
798        assert!(back.value.is_none());
799    }
800
801    #[test]
802    fn test_response_approval_serde_roundtrip() {
803        let resp = InteractionResponse::approval("a1", false, ApprovalScope::Session);
804        let json = serde_json::to_string(&resp).unwrap();
805        let back: InteractionResponse = serde_json::from_str(&json).unwrap();
806        assert_eq!(back.approved, Some(false));
807        assert_eq!(back.scope, Some(ApprovalScope::Session));
808    }
809
810    #[test]
811    fn test_response_as_text_with_value() {
812        let r = InteractionResponse::text("id", "some text value");
813        assert_eq!(response_as_text(&r), "some text value");
814    }
815
816    #[test]
817    fn test_response_approved_session_scope() {
818        let r = InteractionResponse::approval("id", true, ApprovalScope::Session);
819        assert!(response_approved(&r));
820        assert_eq!(r.scope, Some(ApprovalScope::Session));
821    }
822
823    #[test]
824    fn test_make_interaction_id_various() {
825        assert_eq!(make_interaction_id(1, 2), "1-2");
826        assert_eq!(make_interaction_id(10, 20), "10-20");
827    }
828
829    #[test]
830    fn test_default_true_via_serde_missing_required_field() {
831        // JSON without `required` - should default to true via default_true()
832        let json = r#"{
833            "id": "dt1",
834            "kind": "free_text",
835            "prompt": "test prompt",
836            "stage_name": "stage"
837        }"#;
838        let req: InteractionRequest = serde_json::from_str(json).unwrap();
839        assert!(req.required);
840    }
841}