1use serde_json::Value;
4
5use super::model::{
6 AgentResponse, AgentResponseParseError, AgentResponseSummary, ProtocolRequestProfile,
7};
8
9pub fn normalize_turn_response(
17 mut response: AgentResponse,
18 protocol_profile: ProtocolRequestProfile,
19) -> AgentResponse {
20 if matches!(protocol_profile, ProtocolRequestProfile::SessionTurn) && response.summary.is_none()
21 {
22 response.summary = Some(AgentResponseSummary {
23 turn: String::new(),
24 session: String::new(),
25 });
26 }
27
28 response
29}
30
31pub fn parse_agent_response_strict(raw: &str) -> Result<AgentResponse, AgentResponseParseError> {
48 let trimmed = raw.trim();
49 if trimmed.is_empty() {
50 return Err(AgentResponseParseError::Empty);
51 }
52
53 let direct_parse = parse_structured_json_response_with_reason(trimmed);
54 if let Ok(response) = direct_parse {
55 return Ok(response);
56 }
57
58 let direct_parse_error = match direct_parse {
59 Err(error) => error.to_string(),
60 Ok(_) => unreachable!("direct parse branch already returned successful parse"),
61 };
62
63 if let Some(inner) = strip_markdown_code_fence(trimmed) {
64 if let Some(response) = parse_structured_json_response_with_recovery(inner) {
65 return Ok(response);
66 }
67
68 let fence_parse_error = parse_structured_json_response_with_reason(inner)
69 .err()
70 .map_or_else(
71 || "no protocol payload found in markdown code fence".to_string(),
72 |error| error.to_string(),
73 );
74
75 return Err(AgentResponseParseError::InvalidFormat {
76 reason: format!("markdown code fence extraction failed ({fence_parse_error})"),
77 });
78 }
79
80 if let Some(inner) = find_embedded_code_fence_content(trimmed)
81 && let Some(response) = parse_structured_json_response_with_recovery(inner)
82 {
83 return Ok(response);
84 }
85
86 if let Some(response) = recover_embedded_structured_json_response(trimmed) {
87 return Ok(response);
88 }
89
90 Err(AgentResponseParseError::InvalidFormat {
91 reason: format!(
92 "direct parse failed ({direct_parse_error}); no markdown wrapper/embedded protocol \
93 object found"
94 ),
95 })
96}
97
98pub fn format_protocol_parse_debug_details(raw: &str) -> String {
104 let trimmed = raw.trim();
105 let mut detail_lines = vec![
106 format!("response_len: {} chars", raw.chars().count()),
107 format!("response_lines: {}", raw.lines().count()),
108 format!("trimmed_len: {} chars", trimmed.chars().count()),
109 format!(
110 "wrapped_in_markdown_fence: {}",
111 strip_markdown_code_fence(trimmed).is_some()
112 ),
113 ];
114
115 if trimmed.is_empty() {
116 return detail_lines.join("\n");
117 }
118
119 push_character_boundary_debug_lines(&mut detail_lines, trimmed);
120 push_json_debug_lines(&mut detail_lines, "direct_json", trimmed);
121
122 if let Some(inner) = strip_markdown_code_fence(trimmed) {
123 detail_lines.push(format!(
124 "code_fence_inner_len: {} chars",
125 inner.chars().count()
126 ));
127 push_json_debug_lines(&mut detail_lines, "code_fence_json", inner);
128 }
129
130 if let Some(embedded_value) = find_last_embedded_json_value(trimmed) {
131 detail_lines.push("embedded_json_candidate: found".to_string());
132 push_json_value_debug_lines(&mut detail_lines, "embedded_json", &embedded_value);
133 } else {
134 detail_lines.push("embedded_json_candidate: none".to_string());
135 }
136
137 detail_lines.push(format!(
138 "response_preview:\n{}",
139 format_debug_preview(trimmed, 240)
140 ));
141
142 detail_lines.join("\n")
143}
144
145fn parse_structured_json_response(raw: &str) -> Option<AgentResponse> {
151 parse_structured_json_response_with_reason(raw).ok()
152}
153
154fn parse_structured_json_response_with_reason(
157 raw: &str,
158) -> Result<AgentResponse, AgentResponseParseError> {
159 let value: Value = serde_json::from_str(raw.trim()).map_err(|error| {
160 AgentResponseParseError::InvalidFormat {
161 reason: format!("invalid JSON ({error})"),
162 }
163 })?;
164
165 if !value_has_recognized_protocol_key(&value) {
166 return Err(AgentResponseParseError::InvalidFormat {
167 reason: format!(
168 "json object is missing all protocol keys ({})",
169 PROTOCOL_KEYS.join(", ")
170 ),
171 });
172 }
173
174 serde_json::from_value(value).map_err(|error| AgentResponseParseError::InvalidFormat {
175 reason: format!("schema validation failed ({error})"),
176 })
177}
178
179const PROTOCOL_KEYS: &[&str] = &["answer", "questions", "summary"];
181
182fn value_has_recognized_protocol_key(value: &Value) -> bool {
185 value
186 .as_object()
187 .is_some_and(|object| PROTOCOL_KEYS.iter().any(|key| object.contains_key(*key)))
188}
189
190fn parse_structured_json_response_with_recovery(raw: &str) -> Option<AgentResponse> {
193 parse_structured_json_response(raw).or_else(|| recover_embedded_structured_json_response(raw))
194}
195
196fn recover_embedded_structured_json_response(raw: &str) -> Option<AgentResponse> {
203 let value = find_last_embedded_json_value(raw)?;
204 if !value_has_recognized_protocol_key(&value) {
205 return None;
206 }
207
208 serde_json::from_value(value).ok()
209}
210
211fn strip_markdown_code_fence(trimmed: &str) -> Option<&str> {
215 let rest = trimmed.strip_prefix("```")?;
216 let body_start = rest.find('\n').map(|index| index + 1)?;
217 let body = &rest[body_start..];
218 let inner = body.strip_suffix("```")?.trim();
219
220 if inner.is_empty() {
221 return None;
222 }
223
224 Some(inner)
225}
226
227fn find_embedded_code_fence_content(raw: &str) -> Option<&str> {
233 let closing_fence_start = raw.rfind("```")?;
234 let before_closing = raw[..closing_fence_start].trim_end();
235
236 let opening_fence_start = before_closing.rfind("```")?;
237 let after_opening_backticks = &before_closing[opening_fence_start + 3..];
238
239 let body_start = after_opening_backticks.find('\n').map(|index| index + 1)?;
240 let inner = &after_opening_backticks[body_start..];
241 let trimmed = inner.trim();
242
243 if trimmed.is_empty() {
244 return None;
245 }
246
247 Some(trimmed)
248}
249
250fn find_last_embedded_json_value(raw: &str) -> Option<Value> {
253 for (start_index, _) in raw.match_indices('{').rev() {
254 let candidate = &raw[start_index..];
255 let mut deserializer = serde_json::Deserializer::from_str(candidate).into_iter::<Value>();
256 let Some(Ok(value)) = deserializer.next() else {
257 continue;
258 };
259 let trailing_text = &candidate[deserializer.byte_offset()..];
260 if !trailing_text.trim().is_empty() {
261 continue;
262 }
263
264 return Some(value);
265 }
266
267 None
268}
269
270fn push_character_boundary_debug_lines(detail_lines: &mut Vec<String>, trimmed: &str) {
272 if let Some(first_character) = trimmed.chars().next() {
273 detail_lines.push(format!("first_non_whitespace_char: {first_character:?}"));
274 }
275
276 if let Some(last_character) = trimmed.chars().last() {
277 detail_lines.push(format!("last_non_whitespace_char: {last_character:?}"));
278 }
279}
280
281fn push_json_debug_lines(detail_lines: &mut Vec<String>, label: &str, raw: &str) {
283 match serde_json::from_str::<Value>(raw) {
284 Ok(value) => push_json_value_debug_lines(detail_lines, label, &value),
285 Err(error) => {
286 detail_lines.push(format!("{label}_error: {error}"));
287 detail_lines.push(format!(
288 "{label}_error_category: {}",
289 describe_json_error_category(&error)
290 ));
291 detail_lines.push(format!(
292 "{label}_error_location: line {}, column {}",
293 error.line(),
294 error.column()
295 ));
296 }
297 }
298}
299
300fn push_json_value_debug_lines(detail_lines: &mut Vec<String>, label: &str, value: &Value) {
302 detail_lines.push(format!("{label}_type: {}", describe_json_type(value)));
303
304 if let Some(object) = value.as_object() {
305 let mut keys = object.keys().cloned().collect::<Vec<_>>();
306 keys.sort_unstable();
307
308 let recognized_keys = PROTOCOL_KEYS
309 .iter()
310 .filter(|key| object.contains_key(**key))
311 .map(|key| (*key).to_string())
312 .collect::<Vec<_>>();
313 let missing_keys = PROTOCOL_KEYS
314 .iter()
315 .filter(|key| !object.contains_key(**key))
316 .map(|key| (*key).to_string())
317 .collect::<Vec<_>>();
318
319 detail_lines.push(format!("{label}_keys: {}", format_debug_list(&keys)));
320 detail_lines.push(format!(
321 "{label}_recognized_protocol_keys: {}",
322 format_debug_list(&recognized_keys)
323 ));
324 detail_lines.push(format!(
325 "{label}_missing_protocol_keys: {}",
326 format_debug_list(&missing_keys)
327 ));
328 }
329}
330
331fn describe_json_type(value: &Value) -> &'static str {
333 match value {
334 Value::Null => "null",
335 Value::Bool(_) => "boolean",
336 Value::Number(_) => "number",
337 Value::String(_) => "string",
338 Value::Array(_) => "array",
339 Value::Object(_) => "object",
340 }
341}
342
343fn describe_json_error_category(error: &serde_json::Error) -> &'static str {
345 match error.classify() {
346 serde_json::error::Category::Io => "io",
347 serde_json::error::Category::Syntax => "syntax",
348 serde_json::error::Category::Data => "data",
349 serde_json::error::Category::Eof => "eof",
350 }
351}
352
353fn format_debug_list(items: &[String]) -> String {
355 if items.is_empty() {
356 return "(none)".to_string();
357 }
358
359 items.join(", ")
360}
361
362fn format_debug_preview(raw: &str, max_chars: usize) -> String {
364 let preview = raw.chars().take(max_chars).collect::<String>();
365 let total_chars = raw.chars().count();
366 if total_chars <= max_chars {
367 return preview;
368 }
369
370 format!(
371 "{preview}\n... [truncated {} chars]",
372 total_chars - max_chars
373 )
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_parse_agent_response_strict_structured_json_payload() {
383 let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
385
386 let response = parse_agent_response_strict(raw);
388
389 assert_eq!(
391 response.expect("response should parse").answer,
392 "Here is my analysis."
393 );
394 }
395
396 #[test]
397 fn test_parse_agent_response_strict_summary_only_payload() {
400 let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
402
403 let response = parse_agent_response_strict(raw);
405
406 assert_eq!(
408 response.expect("response should parse").summary,
409 Some(AgentResponseSummary {
410 session: "Current diff summary".to_string(),
411 turn: "Turn summary".to_string(),
412 })
413 );
414 }
415
416 #[test]
417 fn test_parse_agent_response_strict_recovers_wrapped_text() {
420 let raw = concat!(
422 "Some wrapper text\n",
423 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
424 );
425
426 let response = parse_agent_response_strict(raw);
428
429 assert_eq!(
431 response.expect("response should parse"),
432 AgentResponse::plain("Recovered payload")
433 );
434 }
435
436 #[test]
437 fn test_parse_agent_response_strict_rejects_plain_text() {
439 let raw = "plain text";
441
442 let response = parse_agent_response_strict(raw);
444
445 assert!(response.is_err());
447 }
448
449 #[test]
450 fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
453 let raw = "plain text";
455
456 let details = format_protocol_parse_debug_details(raw);
458
459 assert!(details.contains("response_len: 10 chars"));
461 assert!(details.contains("first_non_whitespace_char: 'p'"));
462 assert!(details.contains("direct_json_error_category: syntax"));
463 assert!(details.contains("direct_json_error_location: line 1, column 1"));
464 assert!(details.contains("embedded_json_candidate: none"));
465 }
466
467 #[test]
468 fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
471 let raw = r#"{"message":"not the expected shape"}"#;
473
474 let details = format_protocol_parse_debug_details(raw);
476
477 assert!(details.contains("direct_json_type: object"));
479 assert!(details.contains("direct_json_keys: message"));
480 assert!(details.contains("direct_json_recognized_protocol_keys: (none)"));
481 assert!(details.contains("direct_json_missing_protocol_keys: answer, questions, summary"));
482 }
483
484 #[test]
485 fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
487 let response = AgentResponse::plain("done");
489
490 let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
492
493 assert_eq!(
495 normalized.summary,
496 Some(AgentResponseSummary {
497 session: String::new(),
498 turn: String::new(),
499 })
500 );
501 }
502
503 #[test]
504 fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
506 let response = AgentResponse::plain("done");
508
509 let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
511
512 assert_eq!(normalized.summary, None);
514 }
515
516 #[test]
517 fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
520 let raw = r#"{"message":"not the expected shape"}"#;
522
523 let response = parse_agent_response_strict(raw);
525
526 assert!(response.is_err());
528 }
529
530 #[test]
531 fn test_parse_agent_response_strict_rejects_empty_json_object() {
534 let raw = "{}";
536
537 let response = parse_agent_response_strict(raw);
539
540 assert!(response.is_err());
542 }
543
544 #[test]
545 fn test_parse_agent_response_strict_strips_code_fenced_payload() {
547 let raw = concat!(
549 "```json\n",
550 r#"{"answer":"Need details.","questions":[],"summary":null}"#,
551 "\n```"
552 );
553
554 let response = parse_agent_response_strict(raw);
556
557 assert_eq!(
559 response.expect("response should parse").answer,
560 "Need details."
561 );
562 }
563
564 #[test]
565 fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
568 let raw = concat!(
570 "\n\n```json\n",
571 r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
572 "\n```\n"
573 );
574
575 let response = parse_agent_response_strict(raw);
577
578 assert_eq!(
580 response.expect("response should parse").answer,
581 "Recovered."
582 );
583 }
584
585 #[test]
586 fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
588 let raw = concat!(
590 "```\n",
591 r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
592 "\n```"
593 );
594
595 let response = parse_agent_response_strict(raw);
597
598 assert_eq!(
600 response.expect("response should parse").answer,
601 "Plain fence."
602 );
603 }
604
605 #[test]
606 fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
609 let raw =
611 r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
612
613 let response = parse_agent_response_strict(raw);
615
616 assert_eq!(response.expect("response should parse").answer, "Hello.");
618 }
619
620 #[test]
621 fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
623 let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
625
626 let response = parse_agent_response_strict(raw);
628
629 let response = response.expect("response should parse");
631 assert_eq!(response.answer, "Done.");
632 assert_eq!(
633 response.summary,
634 Some(AgentResponseSummary {
635 session: "Bug fix session".to_string(),
636 turn: "Fixed bug".to_string(),
637 })
638 );
639 }
640
641 #[test]
642 fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
644 let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
646
647 let response = parse_agent_response_strict(raw);
649
650 let questions = response.expect("response should parse").question_items();
652 assert_eq!(questions.len(), 1);
653 assert_eq!(questions[0].text, "Which approach?");
654 }
655
656 #[test]
657 fn test_parse_agent_response_strict_accepts_questions_without_answer() {
662 let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
664
665 let response = parse_agent_response_strict(raw);
667
668 let response = response.expect("parser should accept questions-only payload");
670 assert_eq!(response.answer, "");
671 assert_eq!(response.question_items().len(), 1);
672 }
673
674 #[test]
675 fn test_parse_agent_response_strict_accepts_summary_without_answer() {
680 let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
682
683 let response = parse_agent_response_strict(raw);
685
686 let response = response.expect("parser should accept summary-only payload");
688 assert_eq!(response.answer, "");
689 assert_eq!(
690 response.summary,
691 Some(AgentResponseSummary {
692 session: "Bug fix session".to_string(),
693 turn: "Fixed bug".to_string(),
694 })
695 );
696 }
697
698 #[test]
699 fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
702 let raw = concat!(
704 "Some wrapper text\n",
705 r#"{"reasoning":"internal thought","confidence":0.9}"#
706 );
707
708 let response = parse_agent_response_strict(raw);
710
711 assert!(response.is_err());
713 }
714
715 #[test]
716 fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
719 let raw = concat!(
721 "Some wrapper text\n",
722 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
723 "\ntrailing wrapper text"
724 );
725
726 let response = parse_agent_response_strict(raw);
728
729 assert!(response.is_err());
731 }
732
733 #[test]
734 fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
737 let raw = concat!(
739 "The commit message looks good. Let me refine it.\n\n",
740 "```json\n",
741 r#"{"answer":"Refined commit message","questions":[],"summary":null}"#,
742 "\n```"
743 );
744
745 let response = parse_agent_response_strict(raw);
747
748 assert_eq!(
750 response.expect("response should parse").answer,
751 "Refined commit message"
752 );
753 }
754}