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
68impl CompositeForm {
69    pub fn accepts(&self, submission: &FormSubmission) -> bool {
70        let FormSubmission::Submitted { answers } = submission else {
71            return true;
72        };
73        answers.len() == self.questions.len()
74            && self
75                .questions
76                .iter()
77                .zip(answers)
78                .all(|(question, answer)| match (&question.kind, answer) {
79                    (_, FormAnswer::Cancelled) => true,
80                    (FormKind::Confirm { .. }, FormAnswer::Confirmed { .. })
81                    | (FormKind::Text { .. }, FormAnswer::TextEntered { .. }) => true,
82                    (
83                        FormKind::SingleSelect { options, .. },
84                        FormAnswer::Selected { index, label },
85                    ) => options.get(*index) == Some(label),
86                    (
87                        FormKind::MultiSelect {
88                            options, min, max, ..
89                        },
90                        FormAnswer::MultiSelected { indices, labels },
91                    ) => {
92                        indices.len() == labels.len()
93                            && min.is_none_or(|min| indices.len() >= min)
94                            && max.is_none_or(|max| indices.len() <= max)
95                            && indices
96                                .iter()
97                                .zip(labels)
98                                .all(|(index, label)| options.get(*index) == Some(label))
99                    }
100                    _ => false,
101                })
102    }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
106pub struct DeferredFormAnswer {
107    pub prompt_id: String,
108    pub form: CompositeForm,
109    pub submission: FormSubmission,
110}
111
112impl DeferredFormAnswer {
113    pub fn as_user_text(&self) -> String {
114        let FormSubmission::Submitted { answers } = &self.submission else {
115            return String::new();
116        };
117        let mut lines = vec!["Answer to an earlier form:".to_string()];
118        for (question, answer) in self.form.questions.iter().zip(answers) {
119            let value = match answer {
120                FormAnswer::Confirmed { value } => value.to_string(),
121                FormAnswer::Selected { label, .. } => label.clone(),
122                FormAnswer::MultiSelected { labels, .. } => labels.join(", "),
123                FormAnswer::TextEntered { text } => text.clone(),
124                FormAnswer::Cancelled => "Cancelled".into(),
125            };
126            lines.push(format!("{}: {}", question.kind.prompt(), value));
127        }
128        lines.join("\n")
129    }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
133pub enum FormSubmission {
134    Submitted { answers: Vec<FormAnswer> },
135    Rejected,
136}
137
138#[derive(Debug, Clone)]
139pub struct PendingForm {
140    pub form_id: String,
141    pub run_id: FlowRunId,
142    pub tool_use_id: String,
143    pub form: CompositeForm,
144    pub kind: FormKind,
145    pub emitted_at: DateTime<Utc>,
146}
147
148// FormAnswer stays tagged so a `Cancelled` response is a first-class
149// choice, not a magic error code. DSL code inspects `answer.kind` first.
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152pub enum FormAnswer {
153    Confirmed {
154        value: bool,
155    },
156    Selected {
157        index: usize,
158        label: String,
159    },
160    MultiSelected {
161        indices: Vec<usize>,
162        labels: Vec<String>,
163    },
164    TextEntered {
165        text: String,
166    },
167    Cancelled,
168}
169
170impl FormAnswer {
171    pub fn discriminator(&self) -> &'static str {
172        match self {
173            Self::Confirmed { .. } => "confirmed",
174            Self::Selected { .. } => "selected",
175            Self::MultiSelected { .. } => "multi_selected",
176            Self::TextEntered { .. } => "text_entered",
177            Self::Cancelled => "cancelled",
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn form_kind_serializes_with_tag() {
188        let k = FormKind::SingleSelect {
189            prompt: "pick".into(),
190            options: vec!["a".into(), "b".into()],
191        };
192        let s = serde_json::to_string(&k).unwrap();
193        assert!(s.contains(r#""kind":"single_select""#));
194        assert!(s.contains(r#""prompt":"pick""#));
195    }
196
197    #[test]
198    fn submitted_answers_must_match_the_question_schema() {
199        let form = CompositeForm {
200            questions: vec![FormQuestion {
201                id: "pick".into(),
202                kind: FormKind::SingleSelect {
203                    prompt: "Choose".into(),
204                    options: vec!["A".into(), "B".into()],
205                },
206            }],
207        };
208        assert!(form.accepts(&FormSubmission::Submitted {
209            answers: vec![FormAnswer::Selected {
210                index: 1,
211                label: "B".into(),
212            }],
213        }));
214        assert!(!form.accepts(&FormSubmission::Submitted {
215            answers: vec![FormAnswer::Selected {
216                index: 1,
217                label: "A".into(),
218            }],
219        }));
220    }
221
222    #[test]
223    fn form_kind_round_trip_confirm() {
224        let k = FormKind::Confirm {
225            prompt: "sure?".into(),
226        };
227        let s = serde_json::to_string(&k).unwrap();
228        let back: FormKind = serde_json::from_str(&s).unwrap();
229        assert_eq!(back, k);
230    }
231
232    #[test]
233    fn form_kind_round_trip_multi_select_omits_empty_bounds() {
234        let k = FormKind::MultiSelect {
235            prompt: "tags".into(),
236            options: vec!["a".into()],
237            min: None,
238            max: Some(2),
239        };
240        let s = serde_json::to_string(&k).unwrap();
241        assert!(!s.contains("\"min\""));
242        assert!(s.contains("\"max\":2"));
243        let back: FormKind = serde_json::from_str(&s).unwrap();
244        assert_eq!(back, k);
245    }
246
247    #[test]
248    fn form_answer_cancelled_serializes_as_tag_only() {
249        let a = FormAnswer::Cancelled;
250        let s = serde_json::to_string(&a).unwrap();
251        assert_eq!(s, r#"{"kind":"cancelled"}"#);
252    }
253
254    #[test]
255    fn form_answer_round_trip_multi_selected() {
256        let a = FormAnswer::MultiSelected {
257            indices: vec![0, 2],
258            labels: vec!["a".into(), "c".into()],
259        };
260        let s = serde_json::to_string(&a).unwrap();
261        let back: FormAnswer = serde_json::from_str(&s).unwrap();
262        assert_eq!(back, a);
263    }
264
265    #[test]
266    fn discriminators_are_stable() {
267        assert_eq!(
268            FormKind::Text {
269                prompt: "".into(),
270                placeholder: None,
271                multiline: false,
272            }
273            .discriminator(),
274            "text"
275        );
276        assert_eq!(FormAnswer::Cancelled.discriminator(), "cancelled");
277    }
278}