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 {
109 let trimmed = raw.trim();
110 let mut detail_lines = vec![
111 format!("response_len: {} chars", raw.chars().count()),
112 format!("response_lines: {}", raw.lines().count()),
113 format!("trimmed_len: {} chars", trimmed.chars().count()),
114 format!(
115 "wrapped_in_markdown_fence: {}",
116 strip_markdown_code_fence(trimmed).is_some()
117 ),
118 ];
119
120 if trimmed.is_empty() {
121 return detail_lines.join("\n");
122 }
123
124 push_character_boundary_debug_lines(&mut detail_lines, trimmed);
125 push_json_debug_lines(&mut detail_lines, "direct_json", trimmed);
126
127 if let Some(inner) = strip_markdown_code_fence(trimmed) {
128 detail_lines.push(format!(
129 "code_fence_inner_len: {} chars",
130 inner.chars().count()
131 ));
132 push_json_debug_lines(&mut detail_lines, "code_fence_json", inner);
133 }
134
135 if let Some(embedded_value) = find_last_embedded_json_value(trimmed) {
136 detail_lines.push("embedded_json_candidate: found".to_string());
137 push_json_value_debug_lines(&mut detail_lines, "embedded_json", &embedded_value);
138 } else {
139 detail_lines.push("embedded_json_candidate: none".to_string());
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
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn test_format_protocol_parse_debug_details_never_quotes_payload() {
370 let raw = format!("{} SECRETTAIL", "payload-filler ".repeat(40));
372
373 let details = format_protocol_parse_debug_details(&raw);
375
376 assert!(!details.contains("payload-filler"));
378 assert!(!details.contains("SECRETTAIL"));
379 assert!(details.contains(&format!("response_len: {} chars", raw.chars().count())));
380 assert!(details.contains("direct_json_error"));
381 }
382
383 #[test]
384 fn test_parse_agent_response_strict_structured_json_payload() {
386 let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
388
389 let response = parse_agent_response_strict(raw);
391
392 assert_eq!(
394 response.expect("response should parse").answer,
395 "Here is my analysis."
396 );
397 }
398
399 #[test]
400 fn test_parse_agent_response_strict_summary_only_payload() {
403 let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
405
406 let response = parse_agent_response_strict(raw);
408
409 assert_eq!(
411 response.expect("response should parse").summary,
412 Some(AgentResponseSummary {
413 session: "Current diff summary".to_string(),
414 turn: "Turn summary".to_string(),
415 })
416 );
417 }
418
419 #[test]
420 fn test_parse_agent_response_strict_recovers_wrapped_text() {
423 let raw = concat!(
425 "Some wrapper text\n",
426 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
427 );
428
429 let response = parse_agent_response_strict(raw);
431
432 assert_eq!(
434 response.expect("response should parse"),
435 AgentResponse::plain("Recovered payload")
436 );
437 }
438
439 #[test]
440 fn test_parse_agent_response_strict_rejects_plain_text() {
442 let raw = "plain text";
444
445 let response = parse_agent_response_strict(raw);
447
448 assert!(response.is_err());
450 }
451
452 #[test]
453 fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
456 let raw = "plain text";
458
459 let details = format_protocol_parse_debug_details(raw);
461
462 assert!(details.contains("response_len: 10 chars"));
464 assert!(details.contains("first_non_whitespace_char: 'p'"));
465 assert!(details.contains("direct_json_error_category: syntax"));
466 assert!(details.contains("direct_json_error_location: line 1, column 1"));
467 assert!(details.contains("embedded_json_candidate: none"));
468 }
469
470 #[test]
471 fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
474 let raw = r#"{"message":"not the expected shape"}"#;
476
477 let details = format_protocol_parse_debug_details(raw);
479
480 assert!(details.contains("direct_json_type: object"));
482 assert!(details.contains("direct_json_keys: message"));
483 assert!(details.contains("direct_json_recognized_protocol_keys: (none)"));
484 assert!(details.contains("direct_json_missing_protocol_keys: answer, questions, summary"));
485 }
486
487 #[test]
488 fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
490 let response = AgentResponse::plain("done");
492
493 let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
495
496 assert_eq!(
498 normalized.summary,
499 Some(AgentResponseSummary {
500 session: String::new(),
501 turn: String::new(),
502 })
503 );
504 }
505
506 #[test]
507 fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
509 let response = AgentResponse::plain("done");
511
512 let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
514
515 assert_eq!(normalized.summary, None);
517 }
518
519 #[test]
520 fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
523 let raw = r#"{"message":"not the expected shape"}"#;
525
526 let response = parse_agent_response_strict(raw);
528
529 assert!(response.is_err());
531 }
532
533 #[test]
534 fn test_parse_agent_response_strict_rejects_empty_json_object() {
537 let raw = "{}";
539
540 let response = parse_agent_response_strict(raw);
542
543 assert!(response.is_err());
545 }
546
547 #[test]
548 fn test_parse_agent_response_strict_strips_code_fenced_payload() {
550 let raw = concat!(
552 "```json\n",
553 r#"{"answer":"Need details.","questions":[],"summary":null}"#,
554 "\n```"
555 );
556
557 let response = parse_agent_response_strict(raw);
559
560 assert_eq!(
562 response.expect("response should parse").answer,
563 "Need details."
564 );
565 }
566
567 #[test]
568 fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
571 let raw = concat!(
573 "\n\n```json\n",
574 r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
575 "\n```\n"
576 );
577
578 let response = parse_agent_response_strict(raw);
580
581 assert_eq!(
583 response.expect("response should parse").answer,
584 "Recovered."
585 );
586 }
587
588 #[test]
589 fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
591 let raw = concat!(
593 "```\n",
594 r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
595 "\n```"
596 );
597
598 let response = parse_agent_response_strict(raw);
600
601 assert_eq!(
603 response.expect("response should parse").answer,
604 "Plain fence."
605 );
606 }
607
608 #[test]
609 fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
612 let raw =
614 r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
615
616 let response = parse_agent_response_strict(raw);
618
619 assert_eq!(response.expect("response should parse").answer, "Hello.");
621 }
622
623 #[test]
624 fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
626 let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
628
629 let response = parse_agent_response_strict(raw);
631
632 let response = response.expect("response should parse");
634 assert_eq!(response.answer, "Done.");
635 assert_eq!(
636 response.summary,
637 Some(AgentResponseSummary {
638 session: "Bug fix session".to_string(),
639 turn: "Fixed bug".to_string(),
640 })
641 );
642 }
643
644 #[test]
645 fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
647 let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
649
650 let response = parse_agent_response_strict(raw);
652
653 let questions = response.expect("response should parse").question_items();
655 assert_eq!(questions.len(), 1);
656 assert_eq!(questions[0].text, "Which approach?");
657 }
658
659 #[test]
660 fn test_parse_agent_response_strict_accepts_questions_without_answer() {
665 let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
667
668 let response = parse_agent_response_strict(raw);
670
671 let response = response.expect("parser should accept questions-only payload");
673 assert_eq!(response.answer, "");
674 assert_eq!(response.question_items().len(), 1);
675 }
676
677 #[test]
678 fn test_parse_agent_response_strict_accepts_summary_without_answer() {
683 let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
685
686 let response = parse_agent_response_strict(raw);
688
689 let response = response.expect("parser should accept summary-only payload");
691 assert_eq!(response.answer, "");
692 assert_eq!(
693 response.summary,
694 Some(AgentResponseSummary {
695 session: "Bug fix session".to_string(),
696 turn: "Fixed bug".to_string(),
697 })
698 );
699 }
700
701 #[test]
702 fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
705 let raw = concat!(
707 "Some wrapper text\n",
708 r#"{"reasoning":"internal thought","confidence":0.9}"#
709 );
710
711 let response = parse_agent_response_strict(raw);
713
714 assert!(response.is_err());
716 }
717
718 #[test]
719 fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
722 let raw = concat!(
724 "Some wrapper text\n",
725 r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
726 "\ntrailing wrapper text"
727 );
728
729 let response = parse_agent_response_strict(raw);
731
732 assert!(response.is_err());
734 }
735
736 #[test]
737 fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
740 let raw = concat!(
742 "The commit message looks good. Let me refine it.\n\n",
743 "```json\n",
744 r#"{"answer":"Refined commit message","questions":[],"summary":null}"#,
745 "\n```"
746 );
747
748 let response = parse_agent_response_strict(raw);
750
751 assert_eq!(
753 response.expect("response should parse").answer,
754 "Refined commit message"
755 );
756 }
757}