1use serde_json::Value;
4
5use super::model::{
6 AgentResponse, AgentResponseParseError, AgentResponseSummary, ProtocolRequestProfile,
7};
8
9const PROTOCOL_KEYS: &[&str] = &["answer", "questions", "review_comment_outcomes", "summary"];
11
12pub fn normalize_turn_response(
20 mut response: AgentResponse,
21 protocol_profile: ProtocolRequestProfile,
22) -> AgentResponse {
23 if matches!(protocol_profile, ProtocolRequestProfile::SessionTurn) && response.summary.is_none()
24 {
25 response.summary = Some(AgentResponseSummary {
26 session: String::new(),
27 turn: String::new(),
28 });
29 }
30
31 response
32}
33
34pub fn parse_agent_response_strict(raw: &str) -> Result<AgentResponse, AgentResponseParseError> {
52 let trimmed = raw.trim();
53 if trimmed.is_empty() {
54 return Err(AgentResponseParseError::Empty);
55 }
56
57 let direct_parse = parse_structured_json_response_with_reason(trimmed);
58 if let Ok(response) = direct_parse {
59 return Ok(response);
60 }
61
62 let direct_parse_error = match direct_parse {
63 Err(error) => error.to_string(),
64 Ok(_) => unreachable!("direct parse branch already returned successful parse"),
65 };
66
67 if let Some(inner) = strip_markdown_code_fence(trimmed) {
68 if let Some(response) = parse_structured_json_response_with_recovery(inner) {
69 return Ok(response);
70 }
71
72 let fence_parse_error = parse_structured_json_response_with_reason(inner)
73 .err()
74 .map_or_else(
75 || "no protocol payload found in markdown code fence".to_string(),
76 |error| error.to_string(),
77 );
78
79 return Err(AgentResponseParseError::InvalidFormat {
80 reason: format!("markdown code fence extraction failed ({fence_parse_error})"),
81 });
82 }
83
84 if let Some(inner) = find_embedded_code_fence_content(trimmed)
85 && let Some(response) = parse_structured_json_response_with_recovery(inner)
86 {
87 return Ok(response);
88 }
89
90 if let Some(response) = recover_embedded_structured_json_response(trimmed) {
91 return Ok(response);
92 }
93
94 Err(AgentResponseParseError::InvalidFormat {
95 reason: format!(
96 "direct parse failed ({direct_parse_error}); no markdown wrapper/embedded protocol \
97 object found"
98 ),
99 })
100}
101
102pub fn format_protocol_parse_debug_details(raw: &str) -> String {
113 let trimmed = raw.trim();
114 let mut detail_lines = vec![
115 format!("response_len: {} chars", raw.chars().count()),
116 format!("response_lines: {}", raw.lines().count()),
117 format!("trimmed_len: {} chars", trimmed.chars().count()),
118 format!(
119 "wrapped_in_markdown_fence: {}",
120 strip_markdown_code_fence(trimmed).is_some()
121 ),
122 ];
123
124 if trimmed.is_empty() {
125 return detail_lines.join("\n");
126 }
127
128 push_character_boundary_debug_lines(&mut detail_lines, trimmed);
129 push_json_debug_lines(&mut detail_lines, "direct_json", trimmed);
130
131 if let Some(inner) = strip_markdown_code_fence(trimmed) {
132 detail_lines.push(format!(
133 "code_fence_inner_len: {} chars",
134 inner.chars().count()
135 ));
136 push_json_debug_lines(&mut detail_lines, "code_fence_json", inner);
137 }
138
139 if let Some(embedded_value) = find_last_embedded_json_value(trimmed) {
140 detail_lines.push("embedded_json_candidate: found".to_string());
141 push_json_value_debug_lines(&mut detail_lines, "embedded_json", &embedded_value);
142 } else {
143 detail_lines.push("embedded_json_candidate: none".to_string());
144 }
145
146 detail_lines.join("\n")
147}
148
149fn parse_structured_json_response_with_reason(
152 raw: &str,
153) -> Result<AgentResponse, AgentResponseParseError> {
154 let value: Value = serde_json::from_str(raw.trim()).map_err(|error| {
155 AgentResponseParseError::InvalidFormat {
156 reason: format!("invalid JSON ({error})"),
157 }
158 })?;
159
160 if !value_has_recognized_protocol_key(&value) {
161 return Err(AgentResponseParseError::InvalidFormat {
162 reason: format!(
163 "json object is missing all protocol keys ({})",
164 PROTOCOL_KEYS.join(", ")
165 ),
166 });
167 }
168
169 serde_json::from_value(value).map_err(|error| AgentResponseParseError::InvalidFormat {
170 reason: format!("schema validation failed ({error})"),
171 })
172}
173
174fn value_has_recognized_protocol_key(value: &Value) -> bool {
177 value
178 .as_object()
179 .is_some_and(|object| PROTOCOL_KEYS.iter().any(|key| object.contains_key(*key)))
180}
181
182fn strip_markdown_code_fence(trimmed: &str) -> Option<&str> {
186 let rest = trimmed.strip_prefix("```")?;
187 let body_start = rest.find('\n').map(|index| index + 1)?;
188 let body = &rest[body_start..];
189 let inner = body.strip_suffix("```")?.trim();
190
191 if inner.is_empty() {
192 return None;
193 }
194
195 Some(inner)
196}
197
198fn parse_structured_json_response_with_recovery(raw: &str) -> Option<AgentResponse> {
201 parse_structured_json_response(raw).or_else(|| recover_embedded_structured_json_response(raw))
202}
203
204fn parse_structured_json_response(raw: &str) -> Option<AgentResponse> {
210 parse_structured_json_response_with_reason(raw).ok()
211}
212
213fn recover_embedded_structured_json_response(raw: &str) -> Option<AgentResponse> {
220 let value = find_last_embedded_json_value(raw)?;
221 if !value_has_recognized_protocol_key(&value) {
222 return None;
223 }
224
225 serde_json::from_value(value).ok()
226}
227
228fn find_embedded_code_fence_content(raw: &str) -> Option<&str> {
234 let closing_fence_start = raw.rfind("```")?;
235 let before_closing = raw[..closing_fence_start].trim_end();
236
237 let opening_fence_start = before_closing.rfind("```")?;
238 let after_opening_backticks = &before_closing[opening_fence_start + 3..];
239
240 let body_start = after_opening_backticks.find('\n').map(|index| index + 1)?;
241 let inner = &after_opening_backticks[body_start..];
242 let trimmed = inner.trim();
243
244 if trimmed.is_empty() {
245 return None;
246 }
247
248 Some(trimmed)
249}
250
251fn find_last_embedded_json_value(raw: &str) -> Option<Value> {
254 for (start_index, _) in raw.match_indices('{').rev() {
255 let candidate = &raw[start_index..];
256 let mut deserializer = serde_json::Deserializer::from_str(candidate).into_iter::<Value>();
257 let Some(Ok(value)) = deserializer.next() else {
258 continue;
259 };
260 let trailing_text = &candidate[deserializer.byte_offset()..];
261 if !trailing_text.trim().is_empty() {
262 continue;
263 }
264
265 return Some(value);
266 }
267
268 None
269}
270
271fn push_character_boundary_debug_lines(detail_lines: &mut Vec<String>, trimmed: &str) {
273 if let Some(first_character) = trimmed.chars().next() {
274 detail_lines.push(format!("first_non_whitespace_char: {first_character:?}"));
275 }
276
277 if let Some(last_character) = trimmed.chars().last() {
278 detail_lines.push(format!("last_non_whitespace_char: {last_character:?}"));
279 }
280}
281
282fn push_json_debug_lines(detail_lines: &mut Vec<String>, label: &str, raw: &str) {
284 match serde_json::from_str::<Value>(raw) {
285 Ok(value) => push_json_value_debug_lines(detail_lines, label, &value),
286 Err(error) => {
287 detail_lines.push(format!("{label}_error: {error}"));
288 detail_lines.push(format!(
289 "{label}_error_category: {}",
290 describe_json_error_category(&error)
291 ));
292 detail_lines.push(format!(
293 "{label}_error_location: line {}, column {}",
294 error.line(),
295 error.column()
296 ));
297 }
298 }
299}
300
301fn push_json_value_debug_lines(detail_lines: &mut Vec<String>, label: &str, value: &Value) {
303 detail_lines.push(format!("{label}_type: {}", describe_json_type(value)));
304
305 if let Some(object) = value.as_object() {
306 let mut keys = object.keys().cloned().collect::<Vec<_>>();
307 keys.sort_unstable();
308
309 let recognized_keys = PROTOCOL_KEYS
310 .iter()
311 .filter(|key| object.contains_key(**key))
312 .map(|key| (*key).to_string())
313 .collect::<Vec<_>>();
314 let missing_keys = PROTOCOL_KEYS
315 .iter()
316 .filter(|key| !object.contains_key(**key))
317 .map(|key| (*key).to_string())
318 .collect::<Vec<_>>();
319
320 detail_lines.push(format!("{label}_keys: {}", format_debug_list(&keys)));
321 detail_lines.push(format!(
322 "{label}_recognized_protocol_keys: {}",
323 format_debug_list(&recognized_keys)
324 ));
325 detail_lines.push(format!(
326 "{label}_missing_protocol_keys: {}",
327 format_debug_list(&missing_keys)
328 ));
329 }
330}
331
332fn describe_json_type(value: &Value) -> &'static str {
334 match value {
335 Value::Null => "null",
336 Value::Bool(_) => "boolean",
337 Value::Number(_) => "number",
338 Value::String(_) => "string",
339 Value::Array(_) => "array",
340 Value::Object(_) => "object",
341 }
342}
343
344fn format_debug_list(items: &[String]) -> String {
346 if items.is_empty() {
347 return "(none)".to_string();
348 }
349
350 items.join(", ")
351}
352
353fn describe_json_error_category(error: &serde_json::Error) -> &'static str {
355 match error.classify() {
356 serde_json::error::Category::Io => "io",
357 serde_json::error::Category::Syntax => "syntax",
358 serde_json::error::Category::Data => "data",
359 serde_json::error::Category::Eof => "eof",
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::{ReviewCommentOutcome, ReviewCommentResolution};
367
368 #[test]
369 fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
371 let response = AgentResponse::plain("done");
373
374 let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
376
377 assert_eq!(
379 normalized.summary,
380 Some(AgentResponseSummary {
381 session: String::new(),
382 turn: String::new(),
383 })
384 );
385 }
386
387 #[test]
388 fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
390 let response = AgentResponse::plain("done");
392
393 let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
395
396 assert_eq!(normalized.summary, None);
398 }
399
400 #[test]
401 fn test_parse_agent_response_strict_structured_json_payload() {
403 let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
405
406 let response = parse_agent_response_strict(raw);
408
409 assert_eq!(
411 response.expect("response should parse").answer,
412 "Here is my analysis."
413 );
414 }
415
416 #[test]
417 fn test_parse_agent_response_strict_summary_only_payload() {
420 let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
422
423 let response = parse_agent_response_strict(raw);
425
426 assert_eq!(
428 response.expect("response should parse").summary,
429 Some(AgentResponseSummary {
430 session: "Current diff summary".to_string(),
431 turn: "Turn summary".to_string(),
432 })
433 );
434 }
435
436 #[test]
437 fn test_parse_agent_response_strict_recovers_wrapped_text() {
440 let raw = concat!(
442 "Some wrapper text\n",
443 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
444 );
445
446 let response = parse_agent_response_strict(raw);
448
449 assert_eq!(
451 response.expect("response should parse"),
452 AgentResponse::plain("Recovered payload")
453 );
454 }
455
456 #[test]
457 fn test_parse_agent_response_strict_rejects_plain_text() {
459 let raw = "plain text";
461
462 let response = parse_agent_response_strict(raw);
464
465 assert!(response.is_err());
467 }
468
469 #[test]
470 fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
473 let raw = r#"{"message":"not the expected shape"}"#;
475
476 let response = parse_agent_response_strict(raw);
478
479 assert!(response.is_err());
481 }
482
483 #[test]
484 fn test_parse_agent_response_strict_rejects_empty_json_object() {
487 let raw = "{}";
489
490 let response = parse_agent_response_strict(raw);
492
493 assert!(response.is_err());
495 }
496
497 #[test]
498 fn test_parse_agent_response_strict_rejects_empty_code_fence() {
501 let raw = "```json\n\n```";
503
504 let response = parse_agent_response_strict(raw);
506
507 assert!(response.is_err());
509 }
510
511 #[test]
512 fn test_parse_agent_response_strict_strips_code_fenced_payload() {
514 let raw = concat!(
516 "```json\n",
517 r#"{"answer":"Need details.","questions":[],"summary":null}"#,
518 "\n```"
519 );
520
521 let response = parse_agent_response_strict(raw);
523
524 assert_eq!(
526 response.expect("response should parse").answer,
527 "Need details."
528 );
529 }
530
531 #[test]
532 fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
535 let raw = concat!(
537 "\n\n```json\n",
538 r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
539 "\n```\n"
540 );
541
542 let response = parse_agent_response_strict(raw);
544
545 assert_eq!(
547 response.expect("response should parse").answer,
548 "Recovered."
549 );
550 }
551
552 #[test]
553 fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
555 let raw = concat!(
557 "```\n",
558 r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
559 "\n```"
560 );
561
562 let response = parse_agent_response_strict(raw);
564
565 assert_eq!(
567 response.expect("response should parse").answer,
568 "Plain fence."
569 );
570 }
571
572 #[test]
573 fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
576 let raw =
578 r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
579
580 let response = parse_agent_response_strict(raw);
582
583 assert_eq!(response.expect("response should parse").answer, "Hello.");
585 }
586
587 #[test]
588 fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
590 let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
592
593 let response = parse_agent_response_strict(raw);
595
596 let response = response.expect("response should parse");
598 assert_eq!(response.answer, "Done.");
599 assert_eq!(
600 response.summary,
601 Some(AgentResponseSummary {
602 session: "Bug fix session".to_string(),
603 turn: "Fixed bug".to_string(),
604 })
605 );
606 }
607
608 #[test]
609 fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
611 let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
613
614 let response = parse_agent_response_strict(raw);
616
617 let questions = response.expect("response should parse").question_items();
619 assert_eq!(questions.len(), 1);
620 assert_eq!(questions[0].text, "Which approach?");
621 }
622
623 #[test]
624 fn test_parse_agent_response_strict_accepts_questions_without_answer() {
629 let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
631
632 let response = parse_agent_response_strict(raw);
634
635 let response = response.expect("parser should accept questions-only payload");
637 assert_eq!(response.answer, "");
638 assert_eq!(response.question_items().len(), 1);
639 }
640
641 #[test]
642 fn test_parse_agent_response_strict_accepts_review_outcome_without_answer() {
645 let raw = concat!(
647 r#"{"review_comment_outcomes":[{"reply":"Fixed it.","resolution":"fixed","#,
648 r#""thread_id":"thread-42"}]}"#
649 );
650
651 let response = parse_agent_response_strict(raw);
653
654 let response = response.expect("parser should accept review-outcome-only payload");
656 assert_eq!(
657 response.review_comment_outcomes,
658 vec![ReviewCommentOutcome {
659 reply: "Fixed it.".to_string(),
660 resolution: ReviewCommentResolution::Fixed,
661 thread_id: "thread-42".to_string(),
662 }]
663 );
664 }
665
666 #[test]
667 fn test_parse_agent_response_strict_accepts_summary_without_answer() {
672 let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
674
675 let response = parse_agent_response_strict(raw);
677
678 let response = response.expect("parser should accept summary-only payload");
680 assert_eq!(response.answer, "");
681 assert_eq!(
682 response.summary,
683 Some(AgentResponseSummary {
684 session: "Bug fix session".to_string(),
685 turn: "Fixed bug".to_string(),
686 })
687 );
688 }
689
690 #[test]
691 fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
694 let raw = concat!(
696 "Some wrapper text\n",
697 r#"{"reasoning":"internal thought","confidence":0.9}"#
698 );
699
700 let response = parse_agent_response_strict(raw);
702
703 assert!(response.is_err());
705 }
706
707 #[test]
708 fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
711 let raw = concat!(
713 "Some wrapper text\n",
714 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
715 "\ntrailing wrapper text"
716 );
717
718 let response = parse_agent_response_strict(raw);
720
721 assert!(response.is_err());
723 }
724
725 #[test]
726 fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
729 let raw = concat!(
731 "The commit message looks good. Let me refine it.\n\n",
732 "```json\n",
733 r#"{"answer":"Refined commit message","questions":[],"summary":null}"#,
734 "\n```"
735 );
736
737 let response = parse_agent_response_strict(raw);
739
740 assert_eq!(
742 response.expect("response should parse").answer,
743 "Refined commit message"
744 );
745 }
746
747 #[test]
748 fn test_format_protocol_parse_debug_details_never_quotes_payload() {
751 let raw = format!("{} SECRETTAIL", "payload-filler ".repeat(40));
753
754 let details = format_protocol_parse_debug_details(&raw);
756
757 assert!(!details.contains("payload-filler"));
759 assert!(!details.contains("SECRETTAIL"));
760 assert!(details.contains(&format!("response_len: {} chars", raw.chars().count())));
761 assert!(details.contains("direct_json_error"));
762 }
763
764 #[test]
765 fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
768 let raw = "plain text";
770
771 let details = format_protocol_parse_debug_details(raw);
773
774 assert!(details.contains("response_len: 10 chars"));
776 assert!(details.contains("first_non_whitespace_char: 'p'"));
777 assert!(details.contains("direct_json_error_category: syntax"));
778 assert!(details.contains("direct_json_error_location: line 1, column 1"));
779 assert!(details.contains("embedded_json_candidate: none"));
780 }
781
782 #[test]
783 fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
786 let raw = r#"{"message":"not the expected shape"}"#;
788
789 let details = format_protocol_parse_debug_details(raw);
791
792 assert!(details.contains("direct_json_type: object"));
794 assert!(details.contains("direct_json_keys: message"));
795 assert!(details.contains("direct_json_recognized_protocol_keys: (none)"));
796 assert!(details.contains(
797 "direct_json_missing_protocol_keys: answer, questions, review_comment_outcomes, \
798 summary"
799 ));
800 }
801}