Skip to main content

atman_runtime/
form.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::event::FlowRunId;
5
6// FormKind is what the caller asks the user for. Kept as a tagged enum
7// so DSL calls, event replay, and daemon rendezvous can all round-trip
8// through the same JSON shape.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[serde(tag = "kind", rename_all = "snake_case")]
11pub enum FormKind {
12    Confirm {
13        prompt: String,
14    },
15    SingleSelect {
16        prompt: String,
17        options: Vec<String>,
18    },
19    MultiSelect {
20        prompt: String,
21        options: Vec<String>,
22        #[serde(default, skip_serializing_if = "Option::is_none")]
23        min: Option<usize>,
24        #[serde(default, skip_serializing_if = "Option::is_none")]
25        max: Option<usize>,
26    },
27    Text {
28        prompt: String,
29        #[serde(default, skip_serializing_if = "Option::is_none")]
30        placeholder: Option<String>,
31        #[serde(default)]
32        multiline: bool,
33    },
34}
35
36impl FormKind {
37    pub fn prompt(&self) -> &str {
38        match self {
39            Self::Confirm { prompt }
40            | Self::SingleSelect { prompt, .. }
41            | Self::MultiSelect { prompt, .. }
42            | Self::Text { prompt, .. } => prompt,
43        }
44    }
45
46    pub fn discriminator(&self) -> &'static str {
47        match self {
48            Self::Confirm { .. } => "confirm",
49            Self::SingleSelect { .. } => "single_select",
50            Self::MultiSelect { .. } => "multi_select",
51            Self::Text { .. } => "text",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct FormQuestion {
58    pub id: String,
59    #[serde(flatten)]
60    pub kind: FormKind,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct CompositeForm {
65    pub questions: Vec<FormQuestion>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub enum FormSubmission {
70    Submitted { answers: Vec<FormAnswer> },
71    Rejected,
72}
73
74#[derive(Debug, Clone)]
75pub struct PendingForm {
76    pub form_id: String,
77    pub run_id: FlowRunId,
78    pub tool_use_id: String,
79    pub form: CompositeForm,
80    pub kind: FormKind,
81    pub emitted_at: DateTime<Utc>,
82}
83
84// FormAnswer stays tagged so a `Cancelled` response is a first-class
85// choice, not a magic error code. DSL code inspects `answer.kind` first.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87#[serde(tag = "kind", rename_all = "snake_case")]
88pub enum FormAnswer {
89    Confirmed {
90        value: bool,
91    },
92    Selected {
93        index: usize,
94        label: String,
95    },
96    MultiSelected {
97        indices: Vec<usize>,
98        labels: Vec<String>,
99    },
100    TextEntered {
101        text: String,
102    },
103    Cancelled,
104}
105
106impl FormAnswer {
107    pub fn discriminator(&self) -> &'static str {
108        match self {
109            Self::Confirmed { .. } => "confirmed",
110            Self::Selected { .. } => "selected",
111            Self::MultiSelected { .. } => "multi_selected",
112            Self::TextEntered { .. } => "text_entered",
113            Self::Cancelled => "cancelled",
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn form_kind_serializes_with_tag() {
124        let k = FormKind::SingleSelect {
125            prompt: "pick".into(),
126            options: vec!["a".into(), "b".into()],
127        };
128        let s = serde_json::to_string(&k).unwrap();
129        assert!(s.contains(r#""kind":"single_select""#));
130        assert!(s.contains(r#""prompt":"pick""#));
131    }
132
133    #[test]
134    fn form_kind_round_trip_confirm() {
135        let k = FormKind::Confirm {
136            prompt: "sure?".into(),
137        };
138        let s = serde_json::to_string(&k).unwrap();
139        let back: FormKind = serde_json::from_str(&s).unwrap();
140        assert_eq!(back, k);
141    }
142
143    #[test]
144    fn form_kind_round_trip_multi_select_omits_empty_bounds() {
145        let k = FormKind::MultiSelect {
146            prompt: "tags".into(),
147            options: vec!["a".into()],
148            min: None,
149            max: Some(2),
150        };
151        let s = serde_json::to_string(&k).unwrap();
152        assert!(!s.contains("\"min\""));
153        assert!(s.contains("\"max\":2"));
154        let back: FormKind = serde_json::from_str(&s).unwrap();
155        assert_eq!(back, k);
156    }
157
158    #[test]
159    fn form_answer_cancelled_serializes_as_tag_only() {
160        let a = FormAnswer::Cancelled;
161        let s = serde_json::to_string(&a).unwrap();
162        assert_eq!(s, r#"{"kind":"cancelled"}"#);
163    }
164
165    #[test]
166    fn form_answer_round_trip_multi_selected() {
167        let a = FormAnswer::MultiSelected {
168            indices: vec![0, 2],
169            labels: vec!["a".into(), "c".into()],
170        };
171        let s = serde_json::to_string(&a).unwrap();
172        let back: FormAnswer = serde_json::from_str(&s).unwrap();
173        assert_eq!(back, a);
174    }
175
176    #[test]
177    fn discriminators_are_stable() {
178        assert_eq!(
179            FormKind::Text {
180                prompt: "".into(),
181                placeholder: None,
182                multiline: false,
183            }
184            .discriminator(),
185            "text"
186        );
187        assert_eq!(FormAnswer::Cancelled.discriminator(), "cancelled");
188    }
189}