use bob_core::types::AgentAction;
#[derive(Debug, thiserror::Error)]
pub enum ActionParseError {
#[error("invalid JSON: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("missing required field: {0}")]
MissingField(String),
#[error("unknown action type: {0}")]
UnknownType(String),
}
pub fn parse_action(content: &str) -> Result<AgentAction, ActionParseError> {
let stripped = strip_code_fences(content);
let value: serde_json::Value = serde_json::from_str(stripped)?;
let obj = value.as_object().ok_or_else(|| ActionParseError::MissingField("type".to_owned()))?;
let type_val = obj
.get("type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ActionParseError::MissingField("type".to_owned()))?;
const KNOWN_TYPES: &[&str] = &["final", "tool_call", "ask_user"];
if !KNOWN_TYPES.contains(&type_val) {
return Err(ActionParseError::UnknownType(type_val.to_owned()));
}
let action: AgentAction = serde_json::from_value(value)?;
Ok(action)
}
fn strip_code_fences(input: &str) -> &str {
let trimmed = input.trim();
let without_opening = trimmed.strip_prefix("```json").or_else(|| trimmed.strip_prefix("```"));
match without_opening {
Some(rest) => rest.strip_suffix("```").unwrap_or(rest).trim(),
None => trimmed,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn parse_final_variant() {
let input = json!({"type": "final", "content": "Hello!"}).to_string();
let action = parse_action(&input);
assert!(
matches!(action.as_ref(), Ok(AgentAction::Final { content }) if content == "Hello!")
);
}
#[test]
fn parse_tool_call_variant() {
let input =
json!({"type": "tool_call", "name": "search", "arguments": {"q": "rust"}}).to_string();
let action = parse_action(&input);
assert!(matches!(
action.as_ref(),
Ok(AgentAction::ToolCall { name, arguments })
if name == "search" && *arguments == json!({"q": "rust"})
));
}
#[test]
fn parse_ask_user_variant() {
let input = json!({"type": "ask_user", "question": "Which file?"}).to_string();
let action = parse_action(&input);
assert!(matches!(
action.as_ref(),
Ok(AgentAction::AskUser { question }) if question == "Which file?"
));
}
#[test]
fn reject_missing_type_field() {
let input = json!({"content": "Hello!"}).to_string();
let err = parse_action(&input);
assert!(
matches!(err, Err(ActionParseError::MissingField(_))),
"expected MissingField, got {err:?}",
);
}
#[test]
fn reject_unknown_type() {
let input = json!({"type": "explode", "payload": 42}).to_string();
let err = parse_action(&input);
assert!(
matches!(err, Err(ActionParseError::UnknownType(_))),
"expected UnknownType, got {err:?}",
);
}
#[test]
fn reject_non_json_text() {
let err = parse_action("this is not json");
assert!(
matches!(err, Err(ActionParseError::InvalidJson(_))),
"expected InvalidJson, got {err:?}",
);
}
#[test]
fn reject_missing_required_field_for_variant() {
let input = json!({"type": "tool_call", "name": "search"}).to_string();
let err = parse_action(&input);
assert!(
matches!(
err,
Err(ActionParseError::InvalidJson(_) | ActionParseError::MissingField(_))
),
"expected InvalidJson or MissingField, got {err:?}",
);
}
#[test]
fn handle_json_code_fence() {
let input = format!("```json\n{}\n```", json!({"type": "final", "content": "done"}));
let action = parse_action(&input);
assert!(matches!(action, Ok(AgentAction::Final { .. })));
}
#[test]
fn handle_plain_code_fence() {
let input = format!("```\n{}\n```", json!({"type": "ask_user", "question": "yes?"}));
let action = parse_action(&input);
assert!(matches!(action, Ok(AgentAction::AskUser { .. })));
}
#[test]
fn handle_extra_whitespace() {
let input = format!(" \n\n {} \n\n ", json!({"type": "final", "content": "hi"}));
let action = parse_action(&input);
assert!(matches!(action, Ok(AgentAction::Final { .. })));
}
}