use serde_json::Value;
use super::model::{
AgentResponse, AgentResponseParseError, AgentResponseSummary, ProtocolRequestProfile,
};
pub fn normalize_turn_response(
mut response: AgentResponse,
protocol_profile: ProtocolRequestProfile,
) -> AgentResponse {
if matches!(protocol_profile, ProtocolRequestProfile::SessionTurn) && response.summary.is_none()
{
response.summary = Some(AgentResponseSummary {
turn: String::new(),
session: String::new(),
});
}
response
}
pub fn parse_agent_response_strict(raw: &str) -> Result<AgentResponse, AgentResponseParseError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AgentResponseParseError::Empty);
}
let direct_parse = parse_structured_json_response_with_reason(trimmed);
if let Ok(response) = direct_parse {
return Ok(response);
}
let direct_parse_error = match direct_parse {
Err(error) => error.to_string(),
Ok(_) => unreachable!("direct parse branch already returned successful parse"),
};
if let Some(inner) = strip_markdown_code_fence(trimmed) {
if let Some(response) = parse_structured_json_response_with_recovery(inner) {
return Ok(response);
}
let fence_parse_error = parse_structured_json_response_with_reason(inner)
.err()
.map_or_else(
|| "no protocol payload found in markdown code fence".to_string(),
|error| error.to_string(),
);
return Err(AgentResponseParseError::InvalidFormat {
reason: format!("markdown code fence extraction failed ({fence_parse_error})"),
});
}
if let Some(inner) = find_embedded_code_fence_content(trimmed)
&& let Some(response) = parse_structured_json_response_with_recovery(inner)
{
return Ok(response);
}
if let Some(response) = recover_embedded_structured_json_response(trimmed) {
return Ok(response);
}
Err(AgentResponseParseError::InvalidFormat {
reason: format!(
"direct parse failed ({direct_parse_error}); no markdown wrapper/embedded protocol \
object found"
),
})
}
pub fn format_protocol_parse_debug_details(raw: &str) -> String {
let trimmed = raw.trim();
let mut detail_lines = vec![
format!("response_len: {} chars", raw.chars().count()),
format!("response_lines: {}", raw.lines().count()),
format!("trimmed_len: {} chars", trimmed.chars().count()),
format!(
"wrapped_in_markdown_fence: {}",
strip_markdown_code_fence(trimmed).is_some()
),
];
if trimmed.is_empty() {
return detail_lines.join("\n");
}
push_character_boundary_debug_lines(&mut detail_lines, trimmed);
push_json_debug_lines(&mut detail_lines, "direct_json", trimmed);
if let Some(inner) = strip_markdown_code_fence(trimmed) {
detail_lines.push(format!(
"code_fence_inner_len: {} chars",
inner.chars().count()
));
push_json_debug_lines(&mut detail_lines, "code_fence_json", inner);
}
if let Some(embedded_value) = find_last_embedded_json_value(trimmed) {
detail_lines.push("embedded_json_candidate: found".to_string());
push_json_value_debug_lines(&mut detail_lines, "embedded_json", &embedded_value);
} else {
detail_lines.push("embedded_json_candidate: none".to_string());
}
detail_lines.push(format!(
"response_preview:\n{}",
format_debug_preview(trimmed, 240)
));
detail_lines.join("\n")
}
fn parse_structured_json_response(raw: &str) -> Option<AgentResponse> {
parse_structured_json_response_with_reason(raw).ok()
}
fn parse_structured_json_response_with_reason(
raw: &str,
) -> Result<AgentResponse, AgentResponseParseError> {
let value: Value = serde_json::from_str(raw.trim()).map_err(|error| {
AgentResponseParseError::InvalidFormat {
reason: format!("invalid JSON ({error})"),
}
})?;
if !value_has_recognized_protocol_key(&value) {
return Err(AgentResponseParseError::InvalidFormat {
reason: format!(
"json object is missing all protocol keys ({})",
PROTOCOL_KEYS.join(", ")
),
});
}
serde_json::from_value(value).map_err(|error| AgentResponseParseError::InvalidFormat {
reason: format!("schema validation failed ({error})"),
})
}
const PROTOCOL_KEYS: &[&str] = &["answer", "questions", "summary"];
fn value_has_recognized_protocol_key(value: &Value) -> bool {
value
.as_object()
.is_some_and(|object| PROTOCOL_KEYS.iter().any(|key| object.contains_key(*key)))
}
fn parse_structured_json_response_with_recovery(raw: &str) -> Option<AgentResponse> {
parse_structured_json_response(raw).or_else(|| recover_embedded_structured_json_response(raw))
}
fn recover_embedded_structured_json_response(raw: &str) -> Option<AgentResponse> {
let value = find_last_embedded_json_value(raw)?;
if !value_has_recognized_protocol_key(&value) {
return None;
}
serde_json::from_value(value).ok()
}
fn strip_markdown_code_fence(trimmed: &str) -> Option<&str> {
let rest = trimmed.strip_prefix("```")?;
let body_start = rest.find('\n').map(|index| index + 1)?;
let body = &rest[body_start..];
let inner = body.strip_suffix("```")?.trim();
if inner.is_empty() {
return None;
}
Some(inner)
}
fn find_embedded_code_fence_content(raw: &str) -> Option<&str> {
let closing_fence_start = raw.rfind("```")?;
let before_closing = raw[..closing_fence_start].trim_end();
let opening_fence_start = before_closing.rfind("```")?;
let after_opening_backticks = &before_closing[opening_fence_start + 3..];
let body_start = after_opening_backticks.find('\n').map(|index| index + 1)?;
let inner = &after_opening_backticks[body_start..];
let trimmed = inner.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed)
}
fn find_last_embedded_json_value(raw: &str) -> Option<Value> {
for (start_index, _) in raw.match_indices('{').rev() {
let candidate = &raw[start_index..];
let mut deserializer = serde_json::Deserializer::from_str(candidate).into_iter::<Value>();
let Some(Ok(value)) = deserializer.next() else {
continue;
};
let trailing_text = &candidate[deserializer.byte_offset()..];
if !trailing_text.trim().is_empty() {
continue;
}
return Some(value);
}
None
}
fn push_character_boundary_debug_lines(detail_lines: &mut Vec<String>, trimmed: &str) {
if let Some(first_character) = trimmed.chars().next() {
detail_lines.push(format!("first_non_whitespace_char: {first_character:?}"));
}
if let Some(last_character) = trimmed.chars().last() {
detail_lines.push(format!("last_non_whitespace_char: {last_character:?}"));
}
}
fn push_json_debug_lines(detail_lines: &mut Vec<String>, label: &str, raw: &str) {
match serde_json::from_str::<Value>(raw) {
Ok(value) => push_json_value_debug_lines(detail_lines, label, &value),
Err(error) => {
detail_lines.push(format!("{label}_error: {error}"));
detail_lines.push(format!(
"{label}_error_category: {}",
describe_json_error_category(&error)
));
detail_lines.push(format!(
"{label}_error_location: line {}, column {}",
error.line(),
error.column()
));
}
}
}
fn push_json_value_debug_lines(detail_lines: &mut Vec<String>, label: &str, value: &Value) {
detail_lines.push(format!("{label}_type: {}", describe_json_type(value)));
if let Some(object) = value.as_object() {
let mut keys = object.keys().cloned().collect::<Vec<_>>();
keys.sort_unstable();
let recognized_keys = PROTOCOL_KEYS
.iter()
.filter(|key| object.contains_key(**key))
.map(|key| (*key).to_string())
.collect::<Vec<_>>();
let missing_keys = PROTOCOL_KEYS
.iter()
.filter(|key| !object.contains_key(**key))
.map(|key| (*key).to_string())
.collect::<Vec<_>>();
detail_lines.push(format!("{label}_keys: {}", format_debug_list(&keys)));
detail_lines.push(format!(
"{label}_recognized_protocol_keys: {}",
format_debug_list(&recognized_keys)
));
detail_lines.push(format!(
"{label}_missing_protocol_keys: {}",
format_debug_list(&missing_keys)
));
}
}
fn describe_json_type(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn describe_json_error_category(error: &serde_json::Error) -> &'static str {
match error.classify() {
serde_json::error::Category::Io => "io",
serde_json::error::Category::Syntax => "syntax",
serde_json::error::Category::Data => "data",
serde_json::error::Category::Eof => "eof",
}
}
fn format_debug_list(items: &[String]) -> String {
if items.is_empty() {
return "(none)".to_string();
}
items.join(", ")
}
fn format_debug_preview(raw: &str, max_chars: usize) -> String {
let preview = raw.chars().take(max_chars).collect::<String>();
let total_chars = raw.chars().count();
if total_chars <= max_chars {
return preview;
}
format!(
"{preview}\n... [truncated {} chars]",
total_chars - max_chars
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_agent_response_strict_structured_json_payload() {
let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
let response = parse_agent_response_strict(raw);
assert_eq!(
response.expect("response should parse").answer,
"Here is my analysis."
);
}
#[test]
fn test_parse_agent_response_strict_summary_only_payload() {
let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
let response = parse_agent_response_strict(raw);
assert_eq!(
response.expect("response should parse").summary,
Some(AgentResponseSummary {
session: "Current diff summary".to_string(),
turn: "Turn summary".to_string(),
})
);
}
#[test]
fn test_parse_agent_response_strict_recovers_wrapped_text() {
let raw = concat!(
"Some wrapper text\n",
r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
);
let response = parse_agent_response_strict(raw);
assert_eq!(
response.expect("response should parse"),
AgentResponse::plain("Recovered payload")
);
}
#[test]
fn test_parse_agent_response_strict_rejects_plain_text() {
let raw = "plain text";
let response = parse_agent_response_strict(raw);
assert!(response.is_err());
}
#[test]
fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
let raw = "plain text";
let details = format_protocol_parse_debug_details(raw);
assert!(details.contains("response_len: 10 chars"));
assert!(details.contains("first_non_whitespace_char: 'p'"));
assert!(details.contains("direct_json_error_category: syntax"));
assert!(details.contains("direct_json_error_location: line 1, column 1"));
assert!(details.contains("embedded_json_candidate: none"));
}
#[test]
fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
let raw = r#"{"message":"not the expected shape"}"#;
let details = format_protocol_parse_debug_details(raw);
assert!(details.contains("direct_json_type: object"));
assert!(details.contains("direct_json_keys: message"));
assert!(details.contains("direct_json_recognized_protocol_keys: (none)"));
assert!(details.contains("direct_json_missing_protocol_keys: answer, questions, summary"));
}
#[test]
fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
let response = AgentResponse::plain("done");
let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
assert_eq!(
normalized.summary,
Some(AgentResponseSummary {
session: String::new(),
turn: String::new(),
})
);
}
#[test]
fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
let response = AgentResponse::plain("done");
let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
assert_eq!(normalized.summary, None);
}
#[test]
fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
let raw = r#"{"message":"not the expected shape"}"#;
let response = parse_agent_response_strict(raw);
assert!(response.is_err());
}
#[test]
fn test_parse_agent_response_strict_rejects_empty_json_object() {
let raw = "{}";
let response = parse_agent_response_strict(raw);
assert!(response.is_err());
}
#[test]
fn test_parse_agent_response_strict_strips_code_fenced_payload() {
let raw = concat!(
"```json\n",
r#"{"answer":"Need details.","questions":[],"summary":null}"#,
"\n```"
);
let response = parse_agent_response_strict(raw);
assert_eq!(
response.expect("response should parse").answer,
"Need details."
);
}
#[test]
fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
let raw = concat!(
"\n\n```json\n",
r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
"\n```\n"
);
let response = parse_agent_response_strict(raw);
assert_eq!(
response.expect("response should parse").answer,
"Recovered."
);
}
#[test]
fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
let raw = concat!(
"```\n",
r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
"\n```"
);
let response = parse_agent_response_strict(raw);
assert_eq!(
response.expect("response should parse").answer,
"Plain fence."
);
}
#[test]
fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
let raw =
r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
let response = parse_agent_response_strict(raw);
assert_eq!(response.expect("response should parse").answer, "Hello.");
}
#[test]
fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
let response = parse_agent_response_strict(raw);
let response = response.expect("response should parse");
assert_eq!(response.answer, "Done.");
assert_eq!(
response.summary,
Some(AgentResponseSummary {
session: "Bug fix session".to_string(),
turn: "Fixed bug".to_string(),
})
);
}
#[test]
fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
let response = parse_agent_response_strict(raw);
let questions = response.expect("response should parse").question_items();
assert_eq!(questions.len(), 1);
assert_eq!(questions[0].text, "Which approach?");
}
#[test]
fn test_parse_agent_response_strict_accepts_questions_without_answer() {
let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
let response = parse_agent_response_strict(raw);
let response = response.expect("parser should accept questions-only payload");
assert_eq!(response.answer, "");
assert_eq!(response.question_items().len(), 1);
}
#[test]
fn test_parse_agent_response_strict_accepts_summary_without_answer() {
let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
let response = parse_agent_response_strict(raw);
let response = response.expect("parser should accept summary-only payload");
assert_eq!(response.answer, "");
assert_eq!(
response.summary,
Some(AgentResponseSummary {
session: "Bug fix session".to_string(),
turn: "Fixed bug".to_string(),
})
);
}
#[test]
fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
let raw = concat!(
"Some wrapper text\n",
r#"{"reasoning":"internal thought","confidence":0.9}"#
);
let response = parse_agent_response_strict(raw);
assert!(response.is_err());
}
#[test]
fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
let raw = concat!(
"Some wrapper text\n",
r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
"\ntrailing wrapper text"
);
let response = parse_agent_response_strict(raw);
assert!(response.is_err());
}
#[test]
fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
let raw = concat!(
"The commit message looks good. Let me refine it.\n\n",
"```json\n",
r#"{"answer":"Refined commit message","questions":[],"summary":null}"#,
"\n```"
);
let response = parse_agent_response_strict(raw);
assert_eq!(
response.expect("response should parse").answer,
"Refined commit message"
);
}
}