1use serde_json::Value;
4
5use super::model::{
6 AgentResponse, AgentResponseParseError, AgentResponseSummary, ProtocolRequestProfile,
7};
8
9const PROTOCOL_KEYS: &[&str] = &[
11 "answer",
12 "questions",
13 "review_comment_outcomes",
14 "subtasks",
15 "summary",
16];
17
18pub fn normalize_turn_response(
26 mut response: AgentResponse,
27 protocol_profile: ProtocolRequestProfile,
28) -> AgentResponse {
29 if matches!(protocol_profile, ProtocolRequestProfile::SessionTurn) && response.summary.is_none()
30 {
31 response.summary = Some(AgentResponseSummary {
32 session: String::new(),
33 turn: String::new(),
34 });
35 }
36
37 response
38}
39
40pub fn parse_agent_response_strict(raw: &str) -> Result<AgentResponse, AgentResponseParseError> {
58 let trimmed = raw.trim();
59 if trimmed.is_empty() {
60 return Err(AgentResponseParseError::Empty);
61 }
62
63 let direct_parse = parse_structured_json_response_with_reason(trimmed);
64 if let Ok(response) = direct_parse {
65 return Ok(response);
66 }
67
68 let direct_parse_error = match direct_parse {
69 Err(error) => error.to_string(),
70 Ok(_) => unreachable!("direct parse branch already returned successful parse"),
71 };
72
73 if let Some(inner) = strip_markdown_code_fence(trimmed) {
74 if let Some(response) = parse_structured_json_response_with_recovery(inner) {
75 return Ok(response);
76 }
77
78 let fence_parse_error = parse_structured_json_response_with_reason(inner)
79 .err()
80 .map_or_else(
81 || "no protocol payload found in markdown code fence".to_string(),
82 |error| error.to_string(),
83 );
84
85 return Err(AgentResponseParseError::InvalidFormat {
86 reason: format!("markdown code fence extraction failed ({fence_parse_error})"),
87 });
88 }
89
90 if let Some(inner) = find_embedded_code_fence_content(trimmed)
91 && let Some(response) = parse_structured_json_response_with_recovery(inner)
92 {
93 return Ok(response);
94 }
95
96 if let Some(response) = recover_embedded_structured_json_response(trimmed) {
97 return Ok(response);
98 }
99
100 Err(AgentResponseParseError::InvalidFormat {
101 reason: format!(
102 "direct parse failed ({direct_parse_error}); no markdown wrapper/embedded protocol \
103 object found"
104 ),
105 })
106}
107
108pub fn format_protocol_parse_debug_details(raw: &str) -> String {
119 let trimmed = raw.trim();
120 let mut detail_lines = vec![
121 format!("response_len: {} chars", raw.chars().count()),
122 format!("response_lines: {}", raw.lines().count()),
123 format!("trimmed_len: {} chars", trimmed.chars().count()),
124 format!(
125 "wrapped_in_markdown_fence: {}",
126 strip_markdown_code_fence(trimmed).is_some()
127 ),
128 ];
129
130 if trimmed.is_empty() {
131 return detail_lines.join("\n");
132 }
133
134 push_character_boundary_debug_lines(&mut detail_lines, trimmed);
135 push_json_debug_lines(&mut detail_lines, "direct_json", trimmed);
136
137 if let Some(inner) = strip_markdown_code_fence(trimmed) {
138 detail_lines.push(format!(
139 "code_fence_inner_len: {} chars",
140 inner.chars().count()
141 ));
142 push_json_debug_lines(&mut detail_lines, "code_fence_json", inner);
143 }
144
145 if let Some(embedded_value) = find_last_embedded_json_value(trimmed) {
146 detail_lines.push("embedded_json_candidate: found".to_string());
147 push_json_value_debug_lines(&mut detail_lines, "embedded_json", &embedded_value);
148 } else {
149 detail_lines.push("embedded_json_candidate: none".to_string());
150 }
151
152 detail_lines.join("\n")
153}
154
155fn parse_structured_json_response_with_reason(
158 raw: &str,
159) -> Result<AgentResponse, AgentResponseParseError> {
160 let value: Value = serde_json::from_str(raw.trim()).map_err(|error| {
161 AgentResponseParseError::InvalidFormat {
162 reason: format!("invalid JSON ({error})"),
163 }
164 })?;
165
166 if !value_has_recognized_protocol_key(&value) {
167 return Err(AgentResponseParseError::InvalidFormat {
168 reason: format!(
169 "json object is missing all protocol keys ({})",
170 PROTOCOL_KEYS.join(", ")
171 ),
172 });
173 }
174
175 serde_json::from_value(value).map_err(|error| AgentResponseParseError::InvalidFormat {
176 reason: format!("schema validation failed ({error})"),
177 })
178}
179
180fn value_has_recognized_protocol_key(value: &Value) -> bool {
183 value
184 .as_object()
185 .is_some_and(|object| PROTOCOL_KEYS.iter().any(|key| object.contains_key(*key)))
186}
187
188fn strip_markdown_code_fence(trimmed: &str) -> Option<&str> {
192 let rest = trimmed.strip_prefix("```")?;
193 let body_start = rest.find('\n').map(|index| index + 1)?;
194 let body = &rest[body_start..];
195 let inner = body.strip_suffix("```")?.trim();
196
197 if inner.is_empty() {
198 return None;
199 }
200
201 Some(inner)
202}
203
204fn parse_structured_json_response_with_recovery(raw: &str) -> Option<AgentResponse> {
207 parse_structured_json_response(raw).or_else(|| recover_embedded_structured_json_response(raw))
208}
209
210fn parse_structured_json_response(raw: &str) -> Option<AgentResponse> {
216 parse_structured_json_response_with_reason(raw).ok()
217}
218
219fn recover_embedded_structured_json_response(raw: &str) -> Option<AgentResponse> {
226 let value = find_last_embedded_json_value(raw)?;
227 if !value_has_recognized_protocol_key(&value) {
228 return None;
229 }
230
231 serde_json::from_value(value).ok()
232}
233
234fn find_embedded_code_fence_content(raw: &str) -> Option<&str> {
240 let closing_fence_start = raw.rfind("```")?;
241 let before_closing = raw[..closing_fence_start].trim_end();
242
243 let opening_fence_start = before_closing.rfind("```")?;
244 let after_opening_backticks = &before_closing[opening_fence_start + 3..];
245
246 let body_start = after_opening_backticks.find('\n').map(|index| index + 1)?;
247 let inner = &after_opening_backticks[body_start..];
248 let trimmed = inner.trim();
249
250 if trimmed.is_empty() {
251 return None;
252 }
253
254 Some(trimmed)
255}
256
257fn find_last_embedded_json_value(raw: &str) -> Option<Value> {
260 for (start_index, _) in raw.match_indices('{').rev() {
261 let candidate = &raw[start_index..];
262 let mut deserializer = serde_json::Deserializer::from_str(candidate).into_iter::<Value>();
263 let Some(Ok(value)) = deserializer.next() else {
264 continue;
265 };
266 let trailing_text = &candidate[deserializer.byte_offset()..];
267 if !trailing_text.trim().is_empty() {
268 continue;
269 }
270
271 return Some(value);
272 }
273
274 None
275}
276
277fn push_character_boundary_debug_lines(detail_lines: &mut Vec<String>, trimmed: &str) {
279 if let Some(first_character) = trimmed.chars().next() {
280 detail_lines.push(format!("first_non_whitespace_char: {first_character:?}"));
281 }
282
283 if let Some(last_character) = trimmed.chars().last() {
284 detail_lines.push(format!("last_non_whitespace_char: {last_character:?}"));
285 }
286}
287
288fn push_json_debug_lines(detail_lines: &mut Vec<String>, label: &str, raw: &str) {
290 match serde_json::from_str::<Value>(raw) {
291 Ok(value) => push_json_value_debug_lines(detail_lines, label, &value),
292 Err(error) => {
293 detail_lines.push(format!("{label}_error: {error}"));
294 detail_lines.push(format!(
295 "{label}_error_category: {}",
296 describe_json_error_category(&error)
297 ));
298 detail_lines.push(format!(
299 "{label}_error_location: line {}, column {}",
300 error.line(),
301 error.column()
302 ));
303 }
304 }
305}
306
307fn push_json_value_debug_lines(detail_lines: &mut Vec<String>, label: &str, value: &Value) {
309 detail_lines.push(format!("{label}_type: {}", describe_json_type(value)));
310
311 if let Some(object) = value.as_object() {
312 let mut keys = object.keys().cloned().collect::<Vec<_>>();
313 keys.sort_unstable();
314
315 let recognized_keys = PROTOCOL_KEYS
316 .iter()
317 .filter(|key| object.contains_key(**key))
318 .map(|key| (*key).to_string())
319 .collect::<Vec<_>>();
320 let missing_keys = PROTOCOL_KEYS
321 .iter()
322 .filter(|key| !object.contains_key(**key))
323 .map(|key| (*key).to_string())
324 .collect::<Vec<_>>();
325
326 detail_lines.push(format!("{label}_keys: {}", format_debug_list(&keys)));
327 detail_lines.push(format!(
328 "{label}_recognized_protocol_keys: {}",
329 format_debug_list(&recognized_keys)
330 ));
331 detail_lines.push(format!(
332 "{label}_missing_protocol_keys: {}",
333 format_debug_list(&missing_keys)
334 ));
335 }
336}
337
338fn describe_json_type(value: &Value) -> &'static str {
340 match value {
341 Value::Null => "null",
342 Value::Bool(_) => "boolean",
343 Value::Number(_) => "number",
344 Value::String(_) => "string",
345 Value::Array(_) => "array",
346 Value::Object(_) => "object",
347 }
348}
349
350fn format_debug_list(items: &[String]) -> String {
352 if items.is_empty() {
353 return "(none)".to_string();
354 }
355
356 items.join(", ")
357}
358
359fn describe_json_error_category(error: &serde_json::Error) -> &'static str {
361 match error.classify() {
362 serde_json::error::Category::Io => "io",
363 serde_json::error::Category::Syntax => "syntax",
364 serde_json::error::Category::Data => "data",
365 serde_json::error::Category::Eof => "eof",
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::{ReviewCommentOutcome, ReviewCommentResolution};
373
374 #[test]
375 fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
377 let response = AgentResponse::plain("done");
379
380 let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
382
383 assert_eq!(
385 normalized.summary,
386 Some(AgentResponseSummary {
387 session: String::new(),
388 turn: String::new(),
389 })
390 );
391 }
392
393 #[test]
394 fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
396 let response = AgentResponse::plain("done");
398
399 let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
401
402 assert_eq!(normalized.summary, None);
404 }
405
406 #[test]
407 fn test_parse_agent_response_strict_structured_json_payload() {
409 let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
411
412 let response = parse_agent_response_strict(raw);
414
415 assert_eq!(
417 response.expect("response should parse").answer,
418 "Here is my analysis."
419 );
420 }
421
422 #[test]
423 fn test_parse_agent_response_strict_summary_only_payload() {
426 let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
428
429 let response = parse_agent_response_strict(raw);
431
432 assert_eq!(
434 response.expect("response should parse").summary,
435 Some(AgentResponseSummary {
436 session: "Current diff summary".to_string(),
437 turn: "Turn summary".to_string(),
438 })
439 );
440 }
441
442 #[test]
443 fn test_parse_agent_response_strict_recovers_wrapped_text() {
446 let raw = concat!(
448 "Some wrapper text\n",
449 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
450 );
451
452 let response = parse_agent_response_strict(raw);
454
455 assert_eq!(
457 response.expect("response should parse"),
458 AgentResponse::plain("Recovered payload")
459 );
460 }
461
462 #[test]
463 fn test_parse_agent_response_strict_rejects_plain_text() {
465 let raw = "plain text";
467
468 let response = parse_agent_response_strict(raw);
470
471 assert!(response.is_err());
473 }
474
475 #[test]
476 fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
479 let raw = r#"{"message":"not the expected shape"}"#;
481
482 let response = parse_agent_response_strict(raw);
484
485 assert!(response.is_err());
487 }
488
489 #[test]
490 fn test_parse_agent_response_strict_rejects_empty_json_object() {
493 let raw = "{}";
495
496 let response = parse_agent_response_strict(raw);
498
499 assert!(response.is_err());
501 }
502
503 #[test]
504 fn test_parse_agent_response_strict_rejects_empty_code_fence() {
507 let raw = "```json\n\n```";
509
510 let response = parse_agent_response_strict(raw);
512
513 assert!(response.is_err());
515 }
516
517 #[test]
518 fn test_parse_agent_response_strict_strips_code_fenced_payload() {
520 let raw = concat!(
522 "```json\n",
523 r#"{"answer":"Need details.","questions":[],"summary":null}"#,
524 "\n```"
525 );
526
527 let response = parse_agent_response_strict(raw);
529
530 assert_eq!(
532 response.expect("response should parse").answer,
533 "Need details."
534 );
535 }
536
537 #[test]
538 fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
541 let raw = concat!(
543 "\n\n```json\n",
544 r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
545 "\n```\n"
546 );
547
548 let response = parse_agent_response_strict(raw);
550
551 assert_eq!(
553 response.expect("response should parse").answer,
554 "Recovered."
555 );
556 }
557
558 #[test]
559 fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
561 let raw = concat!(
563 "```\n",
564 r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
565 "\n```"
566 );
567
568 let response = parse_agent_response_strict(raw);
570
571 assert_eq!(
573 response.expect("response should parse").answer,
574 "Plain fence."
575 );
576 }
577
578 #[test]
579 fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
582 let raw =
584 r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
585
586 let response = parse_agent_response_strict(raw);
588
589 assert_eq!(response.expect("response should parse").answer, "Hello.");
591 }
592
593 #[test]
594 fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
596 let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
598
599 let response = parse_agent_response_strict(raw);
601
602 let response = response.expect("response should parse");
604 assert_eq!(response.answer, "Done.");
605 assert_eq!(
606 response.summary,
607 Some(AgentResponseSummary {
608 session: "Bug fix session".to_string(),
609 turn: "Fixed bug".to_string(),
610 })
611 );
612 }
613
614 #[test]
615 fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
617 let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
619
620 let response = parse_agent_response_strict(raw);
622
623 let questions = response.expect("response should parse").question_items();
625 assert_eq!(questions.len(), 1);
626 assert_eq!(questions[0].text, "Which approach?");
627 }
628
629 #[test]
630 fn test_parse_agent_response_strict_accepts_questions_without_answer() {
635 let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
637
638 let response = parse_agent_response_strict(raw);
640
641 let response = response.expect("parser should accept questions-only payload");
643 assert_eq!(response.answer, "");
644 assert_eq!(response.question_items().len(), 1);
645 }
646
647 #[test]
648 fn test_parse_agent_response_strict_accepts_review_outcome_without_answer() {
651 let raw = concat!(
653 r#"{"review_comment_outcomes":[{"reply":"Fixed it.","resolution":"fixed","#,
654 r#""thread_id":"thread-42"}]}"#
655 );
656
657 let response = parse_agent_response_strict(raw);
659
660 let response = response.expect("parser should accept review-outcome-only payload");
662 assert_eq!(
663 response.review_comment_outcomes,
664 vec![ReviewCommentOutcome {
665 reply: "Fixed it.".to_string(),
666 resolution: ReviewCommentResolution::Fixed,
667 thread_id: "thread-42".to_string(),
668 }]
669 );
670 }
671
672 #[test]
673 fn test_parse_agent_response_strict_accepts_summary_without_answer() {
678 let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
680
681 let response = parse_agent_response_strict(raw);
683
684 let response = response.expect("parser should accept summary-only payload");
686 assert_eq!(response.answer, "");
687 assert_eq!(
688 response.summary,
689 Some(AgentResponseSummary {
690 session: "Bug fix session".to_string(),
691 turn: "Fixed bug".to_string(),
692 })
693 );
694 }
695
696 #[test]
697 fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
700 let raw = concat!(
702 "Some wrapper text\n",
703 r#"{"reasoning":"internal thought","confidence":0.9}"#
704 );
705
706 let response = parse_agent_response_strict(raw);
708
709 assert!(response.is_err());
711 }
712
713 #[test]
714 fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
717 let raw = concat!(
719 "Some wrapper text\n",
720 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
721 "\ntrailing wrapper text"
722 );
723
724 let response = parse_agent_response_strict(raw);
726
727 assert!(response.is_err());
729 }
730
731 #[test]
732 fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
735 let raw = concat!(
737 "The commit message looks good. Let me refine it.\n\n",
738 "```json\n",
739 r#"{"answer":"Refined commit message","questions":[],"summary":null}"#,
740 "\n```"
741 );
742
743 let response = parse_agent_response_strict(raw);
745
746 assert_eq!(
748 response.expect("response should parse").answer,
749 "Refined commit message"
750 );
751 }
752
753 #[test]
754 fn test_format_protocol_parse_debug_details_never_quotes_payload() {
757 let raw = format!("{} SECRETTAIL", "payload-filler ".repeat(40));
759
760 let details = format_protocol_parse_debug_details(&raw);
762
763 assert!(!details.contains("payload-filler"));
765 assert!(!details.contains("SECRETTAIL"));
766 assert!(details.contains(&format!("response_len: {} chars", raw.chars().count())));
767 assert!(details.contains("direct_json_error"));
768 }
769
770 #[test]
771 fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
774 let raw = "plain text";
776
777 let details = format_protocol_parse_debug_details(raw);
779
780 assert!(details.contains("response_len: 10 chars"));
782 assert!(details.contains("first_non_whitespace_char: 'p'"));
783 assert!(details.contains("direct_json_error_category: syntax"));
784 assert!(details.contains("direct_json_error_location: line 1, column 1"));
785 assert!(details.contains("embedded_json_candidate: none"));
786 }
787
788 #[test]
789 fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
792 let raw = r#"{"message":"not the expected shape"}"#;
794
795 let details = format_protocol_parse_debug_details(raw);
797
798 assert!(details.contains("direct_json_type: object"));
800 assert!(details.contains("direct_json_keys: message"));
801 assert!(details.contains("direct_json_recognized_protocol_keys: (none)"));
802 assert!(details.contains(
803 "direct_json_missing_protocol_keys: answer, questions, review_comment_outcomes, \
804 subtasks, summary"
805 ));
806 }
807}