1use bob_core::types::AgentAction;
38
39#[derive(Debug, thiserror::Error)]
43pub enum ActionParseError {
44 #[error("invalid JSON: {0}")]
46 InvalidJson(#[from] serde_json::Error),
47
48 #[error("missing required field: {0}")]
50 MissingField(String),
51
52 #[error("unknown action type: {0}")]
54 UnknownType(String),
55}
56
57pub fn parse_action(content: &str) -> Result<AgentAction, ActionParseError> {
64 let stripped = strip_code_fences(content);
65
66 let value: serde_json::Value = serde_json::from_str(stripped)?;
68
69 let obj = value.as_object().ok_or_else(|| ActionParseError::MissingField("type".to_owned()))?;
70
71 let type_val = obj
73 .get("type")
74 .and_then(serde_json::Value::as_str)
75 .ok_or_else(|| ActionParseError::MissingField("type".to_owned()))?;
76
77 const KNOWN_TYPES: &[&str] = &["final", "tool_call", "ask_user"];
80 if !KNOWN_TYPES.contains(&type_val) {
81 return Err(ActionParseError::UnknownType(type_val.to_owned()));
82 }
83
84 let action: AgentAction = serde_json::from_value(value)?;
87 Ok(action)
88}
89
90fn strip_code_fences(input: &str) -> &str {
94 let trimmed = input.trim();
95 let without_opening = trimmed.strip_prefix("```json").or_else(|| trimmed.strip_prefix("```"));
97 match without_opening {
98 Some(rest) => rest.strip_suffix("```").unwrap_or(rest).trim(),
99 None => trimmed,
100 }
101}
102
103#[cfg(test)]
106mod tests {
107 use serde_json::json;
108
109 use super::*;
110
111 #[test]
114 fn parse_final_variant() {
115 let input = json!({"type": "final", "content": "Hello!"}).to_string();
116 let action = parse_action(&input);
117 assert!(
118 matches!(action.as_ref(), Ok(AgentAction::Final { content }) if content == "Hello!")
119 );
120 }
121
122 #[test]
123 fn parse_tool_call_variant() {
124 let input =
125 json!({"type": "tool_call", "name": "search", "arguments": {"q": "rust"}}).to_string();
126 let action = parse_action(&input);
127 assert!(matches!(
128 action.as_ref(),
129 Ok(AgentAction::ToolCall { name, arguments })
130 if name == "search" && *arguments == json!({"q": "rust"})
131 ));
132 }
133
134 #[test]
135 fn parse_ask_user_variant() {
136 let input = json!({"type": "ask_user", "question": "Which file?"}).to_string();
137 let action = parse_action(&input);
138 assert!(matches!(
139 action.as_ref(),
140 Ok(AgentAction::AskUser { question }) if question == "Which file?"
141 ));
142 }
143
144 #[test]
147 fn reject_missing_type_field() {
148 let input = json!({"content": "Hello!"}).to_string();
149 let err = parse_action(&input);
150 assert!(
151 matches!(err, Err(ActionParseError::MissingField(_))),
152 "expected MissingField, got {err:?}",
153 );
154 }
155
156 #[test]
157 fn reject_unknown_type() {
158 let input = json!({"type": "explode", "payload": 42}).to_string();
159 let err = parse_action(&input);
160 assert!(
161 matches!(err, Err(ActionParseError::UnknownType(_))),
162 "expected UnknownType, got {err:?}",
163 );
164 }
165
166 #[test]
167 fn reject_non_json_text() {
168 let err = parse_action("this is not json");
169 assert!(
170 matches!(err, Err(ActionParseError::InvalidJson(_))),
171 "expected InvalidJson, got {err:?}",
172 );
173 }
174
175 #[test]
176 fn reject_missing_required_field_for_variant() {
177 let input = json!({"type": "tool_call", "name": "search"}).to_string();
179 let err = parse_action(&input);
180 assert!(
181 matches!(
182 err,
183 Err(ActionParseError::InvalidJson(_) | ActionParseError::MissingField(_))
184 ),
185 "expected InvalidJson or MissingField, got {err:?}",
186 );
187 }
188
189 #[test]
192 fn handle_json_code_fence() {
193 let input = format!("```json\n{}\n```", json!({"type": "final", "content": "done"}));
194 let action = parse_action(&input);
195 assert!(matches!(action, Ok(AgentAction::Final { .. })));
196 }
197
198 #[test]
199 fn handle_plain_code_fence() {
200 let input = format!("```\n{}\n```", json!({"type": "ask_user", "question": "yes?"}));
201 let action = parse_action(&input);
202 assert!(matches!(action, Ok(AgentAction::AskUser { .. })));
203 }
204
205 #[test]
206 fn handle_extra_whitespace() {
207 let input = format!(" \n\n {} \n\n ", json!({"type": "final", "content": "hi"}));
208 let action = parse_action(&input);
209 assert!(matches!(action, Ok(AgentAction::Final { .. })));
210 }
211}