Skip to main content

ag_protocol/
parse.rs

1//! Structured response parsing and streaming normalization helpers.
2
3use serde_json::Value;
4
5use super::model::{
6    AgentResponse, AgentResponseParseError, AgentResponseSummary, ProtocolRequestProfile,
7};
8
9/// Top-level keys the protocol recognizes in a structured response payload.
10const PROTOCOL_KEYS: &[&str] = &[
11    "answer",
12    "questions",
13    "review_comment_outcomes",
14    "subtasks",
15    "summary",
16];
17
18/// Normalizes one parsed turn response according to the request profile.
19///
20/// Interactive session turns expect a summary block on every response so the
21/// worker can persist and render a `Change Summary` section even when no
22/// change text exists. Some providers still emit `summary: null` for compliant
23/// session-turn JSON, so this fills in an empty summary object that downstream
24/// rendering already maps to `No changes`.
25pub 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
40/// Parses one raw assistant message strictly as protocol payload.
41///
42/// The final assistant payload must match [`AgentResponse`] and contain at
43/// least one recognized protocol key (`answer`, `questions`,
44/// `review_comment_outcomes`, `subtasks`, or `summary`).
45///
46/// When a provider prepends stray prose before the final schema object, this
47/// still recovers the trailing protocol payload as long as nothing except
48/// whitespace follows the JSON object. As a further resilience fallback,
49/// markdown code fences wrapping the JSON object are stripped before parsing
50/// when neither direct parsing nor trailing-object recovery succeeds. An
51/// additional fallback extracts JSON from an embedded code fence preceded by
52/// prose text (e.g., commentary followed by a fenced JSON block).
53/// Top-level fields may rely on the wire type's defaults.
54///
55/// # Errors
56/// Returns [`AgentResponseParseError`] when no valid protocol payload is found.
57pub 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
108/// Builds one multi-line debug report for a protocol parsing failure.
109///
110/// The report summarizes response sizing, markdown wrapping, JSON parse
111/// diagnostics, and any visible top-level keys so schema mismatch errors
112/// include enough context to diagnose malformed provider output quickly.
113///
114/// Every line is *derived* metadata: sizes, parser locations, and key names.
115/// No provider payload text is reproduced. Turn errors are rendered into the
116/// session transcript, so quoting the payload here would print raw provider
117/// output into the chat.
118pub 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
155/// Parses one schema-driven JSON response and returns the structured error
156/// detail when the payload cannot be parsed or validated.
157fn 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
180/// Returns whether a parsed JSON value is an object containing at least one
181/// recognized protocol key.
182fn 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
188/// Strips a leading markdown code fence and trailing closing fence from a
189/// trimmed response payload, returning the inner content if the pattern
190/// matches.
191fn 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
204/// Parses one full protocol payload and then falls back to recovering a
205/// trailing schema object from wrapped provider output.
206fn 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
210/// Attempts to parse one schema-driven structured JSON response.
211///
212/// The raw text must parse as a JSON object containing at least one
213/// recognized protocol key from [`PROTOCOL_KEYS`]. Returns `None` when parsing
214/// fails or no recognized keys are present.
215fn parse_structured_json_response(raw: &str) -> Option<AgentResponse> {
216    parse_structured_json_response_with_reason(raw).ok()
217}
218
219/// Recovers one trailing protocol payload from provider output that starts
220/// with extra prose before the final JSON object.
221///
222/// This intentionally keeps trailing text strict: once a candidate JSON object
223/// parses successfully, only whitespace may remain after it. The candidate
224/// must also contain at least one recognized protocol key.
225fn 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
234/// Extracts the inner content from the last markdown code fence embedded in a
235/// response that also contains surrounding prose text.
236///
237/// Handles the pattern where a provider prepends commentary before a fenced
238/// JSON payload (e.g., `"Some explanation\n` ` ```json\n{...}\n``` ` `"`).
239fn 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
257/// Finds the last JSON object embedded in a response when it consumes the full
258/// trailing suffix except for whitespace.
259fn 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
277/// Appends stable character-boundary diagnostics for one trimmed response.
278fn 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
288/// Appends either JSON parse failure details or top-level JSON shape details.
289fn 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
307/// Appends the top-level JSON type and protocol-key visibility for one value.
308fn 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
338/// Returns one stable label for a top-level JSON value type.
339fn 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
350/// Formats one debug list as a comma-separated string or `(none)`.
351fn format_debug_list(items: &[String]) -> String {
352    if items.is_empty() {
353        return "(none)".to_string();
354    }
355
356    items.join(", ")
357}
358
359/// Returns one stable label for the serde JSON error category.
360fn 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    /// Fills in empty summaries for session turns.
376    fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
377        // Arrange
378        let response = AgentResponse::plain("done");
379
380        // Act
381        let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
382
383        // Assert
384        assert_eq!(
385            normalized.summary,
386            Some(AgentResponseSummary {
387                session: String::new(),
388                turn: String::new(),
389            })
390        );
391    }
392
393    #[test]
394    /// Leaves one-shot prompt summaries unset.
395    fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
396        // Arrange
397        let response = AgentResponse::plain("done");
398
399        // Act
400        let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
401
402        // Assert
403        assert_eq!(normalized.summary, None);
404    }
405
406    #[test]
407    /// Strict parsing accepts a complete schema payload.
408    fn test_parse_agent_response_strict_structured_json_payload() {
409        // Arrange
410        let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
411
412        // Act
413        let response = parse_agent_response_strict(raw);
414
415        // Assert
416        assert_eq!(
417            response.expect("response should parse").answer,
418            "Here is my analysis."
419        );
420    }
421
422    #[test]
423    /// Strict parsing accepts summary-only payloads that still match the
424    /// protocol shape.
425    fn test_parse_agent_response_strict_summary_only_payload() {
426        // Arrange
427        let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
428
429        // Act
430        let response = parse_agent_response_strict(raw);
431
432        // Assert
433        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    /// Strict parsing recovers a trailing protocol payload when a provider
444    /// prepends extra prose before the final JSON object.
445    fn test_parse_agent_response_strict_recovers_wrapped_text() {
446        // Arrange
447        let raw = concat!(
448            "Some wrapper text\n",
449            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
450        );
451
452        // Act
453        let response = parse_agent_response_strict(raw);
454
455        // Assert
456        assert_eq!(
457            response.expect("response should parse"),
458            AgentResponse::plain("Recovered payload")
459        );
460    }
461
462    #[test]
463    /// Strict parsing rejects plain text that contains no protocol payload.
464    fn test_parse_agent_response_strict_rejects_plain_text() {
465        // Arrange
466        let raw = "plain text";
467
468        // Act
469        let response = parse_agent_response_strict(raw);
470
471        // Assert
472        assert!(response.is_err());
473    }
474
475    #[test]
476    /// Strict parsing rejects JSON objects with only unrecognized fields
477    /// because at least one protocol key must be present.
478    fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
479        // Arrange
480        let raw = r#"{"message":"not the expected shape"}"#;
481
482        // Act
483        let response = parse_agent_response_strict(raw);
484
485        // Assert
486        assert!(response.is_err());
487    }
488
489    #[test]
490    /// Strict parsing rejects an empty JSON object because no recognized
491    /// protocol key is present.
492    fn test_parse_agent_response_strict_rejects_empty_json_object() {
493        // Arrange
494        let raw = "{}";
495
496        // Act
497        let response = parse_agent_response_strict(raw);
498
499        // Assert
500        assert!(response.is_err());
501    }
502
503    #[test]
504    /// Strict parsing rejects an empty code fence instead of treating it as a
505    /// structured response.
506    fn test_parse_agent_response_strict_rejects_empty_code_fence() {
507        // Arrange
508        let raw = "```json\n\n```";
509
510        // Act
511        let response = parse_agent_response_strict(raw);
512
513        // Assert
514        assert!(response.is_err());
515    }
516
517    #[test]
518    /// Strict parsing strips code fences and recovers the inner JSON payload.
519    fn test_parse_agent_response_strict_strips_code_fenced_payload() {
520        // Arrange
521        let raw = concat!(
522            "```json\n",
523            r#"{"answer":"Need details.","questions":[],"summary":null}"#,
524            "\n```"
525        );
526
527        // Act
528        let response = parse_agent_response_strict(raw);
529
530        // Assert
531        assert_eq!(
532            response.expect("response should parse").answer,
533            "Need details."
534        );
535    }
536
537    #[test]
538    /// Strict parsing strips code fences even when leading/trailing whitespace
539    /// surrounds the fenced block.
540    fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
541        // Arrange
542        let raw = concat!(
543            "\n\n```json\n",
544            r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
545            "\n```\n"
546        );
547
548        // Act
549        let response = parse_agent_response_strict(raw);
550
551        // Assert
552        assert_eq!(
553            response.expect("response should parse").answer,
554            "Recovered."
555        );
556    }
557
558    #[test]
559    /// Strict parsing strips plain code fences without a language tag.
560    fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
561        // Arrange
562        let raw = concat!(
563            "```\n",
564            r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
565            "\n```"
566        );
567
568        // Act
569        let response = parse_agent_response_strict(raw);
570
571        // Assert
572        assert_eq!(
573            response.expect("response should parse").answer,
574            "Plain fence."
575        );
576    }
577
578    #[test]
579    /// Strict parsing tolerates extra top-level fields that providers may add
580    /// beyond the protocol schema.
581    fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
582        // Arrange
583        let raw =
584            r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
585
586        // Act
587        let response = parse_agent_response_strict(raw);
588
589        // Assert
590        assert_eq!(response.expect("response should parse").answer, "Hello.");
591    }
592
593    #[test]
594    /// Strict parsing tolerates extra fields inside nested summary objects.
595    fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
596        // Arrange
597        let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
598
599        // Act
600        let response = parse_agent_response_strict(raw);
601
602        // Assert
603        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    /// Strict parsing tolerates extra fields inside nested question objects.
616    fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
617        // Arrange
618        let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
619
620        // Act
621        let response = parse_agent_response_strict(raw);
622
623        // Assert
624        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    /// Parser accepts a payload with `questions` but no `answer` key,
631    /// exercising the documented asymmetry where the parser is lenient
632    /// (any recognized key suffices) while the prompt schema requires
633    /// `answer`.
634    fn test_parse_agent_response_strict_accepts_questions_without_answer() {
635        // Arrange
636        let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
637
638        // Act
639        let response = parse_agent_response_strict(raw);
640
641        // Assert
642        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    /// Parser accepts a review-comment outcome without an `answer` key because
649    /// it is a recognized protocol field.
650    fn test_parse_agent_response_strict_accepts_review_outcome_without_answer() {
651        // Arrange
652        let raw = concat!(
653            r#"{"review_comment_outcomes":[{"reply":"Fixed it.","resolution":"fixed","#,
654            r#""thread_id":"thread-42"}]}"#
655        );
656
657        // Act
658        let response = parse_agent_response_strict(raw);
659
660        // Assert
661        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    /// Parser accepts a payload with `summary` but no `answer` key,
674    /// exercising the documented asymmetry where the parser is lenient
675    /// (any recognized key suffices) while the prompt schema requires
676    /// `answer`.
677    fn test_parse_agent_response_strict_accepts_summary_without_answer() {
678        // Arrange
679        let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
680
681        // Act
682        let response = parse_agent_response_strict(raw);
683
684        // Assert
685        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    /// Recovery path skips non-protocol JSON objects embedded in prose when
698    /// they contain no recognized protocol keys.
699    fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
700        // Arrange
701        let raw = concat!(
702            "Some wrapper text\n",
703            r#"{"reasoning":"internal thought","confidence":0.9}"#
704        );
705
706        // Act
707        let response = parse_agent_response_strict(raw);
708
709        // Assert
710        assert!(response.is_err());
711    }
712
713    #[test]
714    /// Strict parsing still rejects trailing wrapper text after a recovered
715    /// schema object.
716    fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
717        // Arrange
718        let raw = concat!(
719            "Some wrapper text\n",
720            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
721            "\ntrailing wrapper text"
722        );
723
724        // Act
725        let response = parse_agent_response_strict(raw);
726
727        // Assert
728        assert!(response.is_err());
729    }
730
731    #[test]
732    /// Strict parsing recovers protocol JSON from an embedded code fence
733    /// preceded by prose text.
734    fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
735        // Arrange
736        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        // Act
744        let response = parse_agent_response_strict(raw);
745
746        // Assert
747        assert_eq!(
748            response.expect("response should parse").answer,
749            "Refined commit message"
750        );
751    }
752
753    #[test]
754    /// The debug report describes the payload without reproducing any of it,
755    /// so a turn error cannot print raw provider output into the transcript.
756    fn test_format_protocol_parse_debug_details_never_quotes_payload() {
757        // Arrange
758        let raw = format!("{} SECRETTAIL", "payload-filler ".repeat(40));
759
760        // Act
761        let details = format_protocol_parse_debug_details(&raw);
762
763        // Assert
764        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    /// Debug formatting reports JSON parser location details for plain-text
772    /// responses that never produced protocol JSON.
773    fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
774        // Arrange
775        let raw = "plain text";
776
777        // Act
778        let details = format_protocol_parse_debug_details(raw);
779
780        // Assert
781        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    /// Debug formatting reports visible top-level keys when the response is
790    /// valid JSON but does not include any protocol fields.
791    fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
792        // Arrange
793        let raw = r#"{"message":"not the expected shape"}"#;
794
795        // Act
796        let details = format_protocol_parse_debug_details(raw);
797
798        // Assert
799        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}