1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::event::FlowRunId;
5
6#[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#[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}