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] = &["answer", "questions", "review_comment_outcomes", "summary"];
11
12/// Normalizes one parsed turn response according to the request profile.
13///
14/// Interactive session turns expect a summary block on every response so the
15/// worker can persist and render a `Change Summary` section even when no
16/// change text exists. Some providers still emit `summary: null` for compliant
17/// session-turn JSON, so this fills in an empty summary object that downstream
18/// rendering already maps to `No changes`.
19pub 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
34/// Parses one raw assistant message strictly as protocol payload.
35///
36/// The final assistant payload must match [`AgentResponse`] and contain at
37/// least one recognized protocol key (`answer`, `questions`,
38/// `review_comment_outcomes`, or `summary`).
39///
40/// When a provider prepends stray prose before the final schema object, this
41/// still recovers the trailing protocol payload as long as nothing except
42/// whitespace follows the JSON object. As a further resilience fallback,
43/// markdown code fences wrapping the JSON object are stripped before parsing
44/// when neither direct parsing nor trailing-object recovery succeeds. An
45/// additional fallback extracts JSON from an embedded code fence preceded by
46/// prose text (e.g., commentary followed by a fenced JSON block).
47/// Top-level fields may rely on the wire type's defaults.
48///
49/// # Errors
50/// Returns [`AgentResponseParseError`] when no valid protocol payload is found.
51pub 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
102/// Builds one multi-line debug report for a protocol parsing failure.
103///
104/// The report summarizes response sizing, markdown wrapping, JSON parse
105/// diagnostics, and any visible top-level keys so schema mismatch errors
106/// include enough context to diagnose malformed provider output quickly.
107///
108/// Every line is *derived* metadata: sizes, parser locations, and key names.
109/// No provider payload text is reproduced. Turn errors are rendered into the
110/// session transcript, so quoting the payload here would print raw provider
111/// output into the chat.
112pub 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
149/// Parses one schema-driven JSON response and returns the structured error
150/// detail when the payload cannot be parsed or validated.
151fn 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
174/// Returns whether a parsed JSON value is an object containing at least one
175/// recognized protocol key.
176fn 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
182/// Strips a leading markdown code fence and trailing closing fence from a
183/// trimmed response payload, returning the inner content if the pattern
184/// matches.
185fn 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
198/// Parses one full protocol payload and then falls back to recovering a
199/// trailing schema object from wrapped provider output.
200fn 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
204/// Attempts to parse one schema-driven structured JSON response.
205///
206/// The raw text must parse as a JSON object containing at least one
207/// recognized protocol key (`answer`, `questions`, or `summary`). Returns
208/// `None` when parsing fails or no recognized keys are present.
209fn parse_structured_json_response(raw: &str) -> Option<AgentResponse> {
210    parse_structured_json_response_with_reason(raw).ok()
211}
212
213/// Recovers one trailing protocol payload from provider output that starts
214/// with extra prose before the final JSON object.
215///
216/// This intentionally keeps trailing text strict: once a candidate JSON object
217/// parses successfully, only whitespace may remain after it. The candidate
218/// must also contain at least one recognized protocol key.
219fn 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
228/// Extracts the inner content from the last markdown code fence embedded in a
229/// response that also contains surrounding prose text.
230///
231/// Handles the pattern where a provider prepends commentary before a fenced
232/// JSON payload (e.g., `"Some explanation\n` ` ```json\n{...}\n``` ` `"`).
233fn 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
251/// Finds the last JSON object embedded in a response when it consumes the full
252/// trailing suffix except for whitespace.
253fn 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
271/// Appends stable character-boundary diagnostics for one trimmed response.
272fn 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
282/// Appends either JSON parse failure details or top-level JSON shape details.
283fn 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
301/// Appends the top-level JSON type and protocol-key visibility for one value.
302fn 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
332/// Returns one stable label for a top-level JSON value type.
333fn 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
344/// Formats one debug list as a comma-separated string or `(none)`.
345fn format_debug_list(items: &[String]) -> String {
346    if items.is_empty() {
347        return "(none)".to_string();
348    }
349
350    items.join(", ")
351}
352
353/// Returns one stable label for the serde JSON error category.
354fn 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    /// Fills in empty summaries for session turns.
370    fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
371        // Arrange
372        let response = AgentResponse::plain("done");
373
374        // Act
375        let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
376
377        // Assert
378        assert_eq!(
379            normalized.summary,
380            Some(AgentResponseSummary {
381                session: String::new(),
382                turn: String::new(),
383            })
384        );
385    }
386
387    #[test]
388    /// Leaves one-shot prompt summaries unset.
389    fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
390        // Arrange
391        let response = AgentResponse::plain("done");
392
393        // Act
394        let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
395
396        // Assert
397        assert_eq!(normalized.summary, None);
398    }
399
400    #[test]
401    /// Strict parsing accepts a complete schema payload.
402    fn test_parse_agent_response_strict_structured_json_payload() {
403        // Arrange
404        let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
405
406        // Act
407        let response = parse_agent_response_strict(raw);
408
409        // Assert
410        assert_eq!(
411            response.expect("response should parse").answer,
412            "Here is my analysis."
413        );
414    }
415
416    #[test]
417    /// Strict parsing accepts summary-only payloads that still match the
418    /// protocol shape.
419    fn test_parse_agent_response_strict_summary_only_payload() {
420        // Arrange
421        let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
422
423        // Act
424        let response = parse_agent_response_strict(raw);
425
426        // Assert
427        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    /// Strict parsing recovers a trailing protocol payload when a provider
438    /// prepends extra prose before the final JSON object.
439    fn test_parse_agent_response_strict_recovers_wrapped_text() {
440        // Arrange
441        let raw = concat!(
442            "Some wrapper text\n",
443            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
444        );
445
446        // Act
447        let response = parse_agent_response_strict(raw);
448
449        // Assert
450        assert_eq!(
451            response.expect("response should parse"),
452            AgentResponse::plain("Recovered payload")
453        );
454    }
455
456    #[test]
457    /// Strict parsing rejects plain text that contains no protocol payload.
458    fn test_parse_agent_response_strict_rejects_plain_text() {
459        // Arrange
460        let raw = "plain text";
461
462        // Act
463        let response = parse_agent_response_strict(raw);
464
465        // Assert
466        assert!(response.is_err());
467    }
468
469    #[test]
470    /// Strict parsing rejects JSON objects with only unrecognized fields
471    /// because at least one protocol key must be present.
472    fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
473        // Arrange
474        let raw = r#"{"message":"not the expected shape"}"#;
475
476        // Act
477        let response = parse_agent_response_strict(raw);
478
479        // Assert
480        assert!(response.is_err());
481    }
482
483    #[test]
484    /// Strict parsing rejects an empty JSON object because no recognized
485    /// protocol key is present.
486    fn test_parse_agent_response_strict_rejects_empty_json_object() {
487        // Arrange
488        let raw = "{}";
489
490        // Act
491        let response = parse_agent_response_strict(raw);
492
493        // Assert
494        assert!(response.is_err());
495    }
496
497    #[test]
498    /// Strict parsing rejects an empty code fence instead of treating it as a
499    /// structured response.
500    fn test_parse_agent_response_strict_rejects_empty_code_fence() {
501        // Arrange
502        let raw = "```json\n\n```";
503
504        // Act
505        let response = parse_agent_response_strict(raw);
506
507        // Assert
508        assert!(response.is_err());
509    }
510
511    #[test]
512    /// Strict parsing strips code fences and recovers the inner JSON payload.
513    fn test_parse_agent_response_strict_strips_code_fenced_payload() {
514        // Arrange
515        let raw = concat!(
516            "```json\n",
517            r#"{"answer":"Need details.","questions":[],"summary":null}"#,
518            "\n```"
519        );
520
521        // Act
522        let response = parse_agent_response_strict(raw);
523
524        // Assert
525        assert_eq!(
526            response.expect("response should parse").answer,
527            "Need details."
528        );
529    }
530
531    #[test]
532    /// Strict parsing strips code fences even when leading/trailing whitespace
533    /// surrounds the fenced block.
534    fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
535        // Arrange
536        let raw = concat!(
537            "\n\n```json\n",
538            r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
539            "\n```\n"
540        );
541
542        // Act
543        let response = parse_agent_response_strict(raw);
544
545        // Assert
546        assert_eq!(
547            response.expect("response should parse").answer,
548            "Recovered."
549        );
550    }
551
552    #[test]
553    /// Strict parsing strips plain code fences without a language tag.
554    fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
555        // Arrange
556        let raw = concat!(
557            "```\n",
558            r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
559            "\n```"
560        );
561
562        // Act
563        let response = parse_agent_response_strict(raw);
564
565        // Assert
566        assert_eq!(
567            response.expect("response should parse").answer,
568            "Plain fence."
569        );
570    }
571
572    #[test]
573    /// Strict parsing tolerates extra top-level fields that providers may add
574    /// beyond the protocol schema.
575    fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
576        // Arrange
577        let raw =
578            r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
579
580        // Act
581        let response = parse_agent_response_strict(raw);
582
583        // Assert
584        assert_eq!(response.expect("response should parse").answer, "Hello.");
585    }
586
587    #[test]
588    /// Strict parsing tolerates extra fields inside nested summary objects.
589    fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
590        // Arrange
591        let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
592
593        // Act
594        let response = parse_agent_response_strict(raw);
595
596        // Assert
597        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    /// Strict parsing tolerates extra fields inside nested question objects.
610    fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
611        // Arrange
612        let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
613
614        // Act
615        let response = parse_agent_response_strict(raw);
616
617        // Assert
618        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    /// Parser accepts a payload with `questions` but no `answer` key,
625    /// exercising the documented asymmetry where the parser is lenient
626    /// (any recognized key suffices) while the prompt schema requires
627    /// `answer`.
628    fn test_parse_agent_response_strict_accepts_questions_without_answer() {
629        // Arrange
630        let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
631
632        // Act
633        let response = parse_agent_response_strict(raw);
634
635        // Assert
636        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    /// Parser accepts a review-comment outcome without an `answer` key because
643    /// it is a recognized protocol field.
644    fn test_parse_agent_response_strict_accepts_review_outcome_without_answer() {
645        // Arrange
646        let raw = concat!(
647            r#"{"review_comment_outcomes":[{"reply":"Fixed it.","resolution":"fixed","#,
648            r#""thread_id":"thread-42"}]}"#
649        );
650
651        // Act
652        let response = parse_agent_response_strict(raw);
653
654        // Assert
655        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    /// Parser accepts a payload with `summary` but no `answer` key,
668    /// exercising the documented asymmetry where the parser is lenient
669    /// (any recognized key suffices) while the prompt schema requires
670    /// `answer`.
671    fn test_parse_agent_response_strict_accepts_summary_without_answer() {
672        // Arrange
673        let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
674
675        // Act
676        let response = parse_agent_response_strict(raw);
677
678        // Assert
679        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    /// Recovery path skips non-protocol JSON objects embedded in prose when
692    /// they contain no recognized protocol keys.
693    fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
694        // Arrange
695        let raw = concat!(
696            "Some wrapper text\n",
697            r#"{"reasoning":"internal thought","confidence":0.9}"#
698        );
699
700        // Act
701        let response = parse_agent_response_strict(raw);
702
703        // Assert
704        assert!(response.is_err());
705    }
706
707    #[test]
708    /// Strict parsing still rejects trailing wrapper text after a recovered
709    /// schema object.
710    fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
711        // Arrange
712        let raw = concat!(
713            "Some wrapper text\n",
714            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
715            "\ntrailing wrapper text"
716        );
717
718        // Act
719        let response = parse_agent_response_strict(raw);
720
721        // Assert
722        assert!(response.is_err());
723    }
724
725    #[test]
726    /// Strict parsing recovers protocol JSON from an embedded code fence
727    /// preceded by prose text.
728    fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
729        // Arrange
730        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        // Act
738        let response = parse_agent_response_strict(raw);
739
740        // Assert
741        assert_eq!(
742            response.expect("response should parse").answer,
743            "Refined commit message"
744        );
745    }
746
747    #[test]
748    /// The debug report describes the payload without reproducing any of it,
749    /// so a turn error cannot print raw provider output into the transcript.
750    fn test_format_protocol_parse_debug_details_never_quotes_payload() {
751        // Arrange
752        let raw = format!("{} SECRETTAIL", "payload-filler ".repeat(40));
753
754        // Act
755        let details = format_protocol_parse_debug_details(&raw);
756
757        // Assert
758        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    /// Debug formatting reports JSON parser location details for plain-text
766    /// responses that never produced protocol JSON.
767    fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
768        // Arrange
769        let raw = "plain text";
770
771        // Act
772        let details = format_protocol_parse_debug_details(raw);
773
774        // Assert
775        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    /// Debug formatting reports visible top-level keys when the response is
784    /// valid JSON but does not include any protocol fields.
785    fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
786        // Arrange
787        let raw = r#"{"message":"not the expected shape"}"#;
788
789        // Act
790        let details = format_protocol_parse_debug_details(raw);
791
792        // Assert
793        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}