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/// Normalizes one parsed turn response according to the request profile.
10///
11/// Interactive session turns expect a summary block on every response so the
12/// worker can persist and render a `Change Summary` section even when no
13/// change text exists. Some providers still emit `summary: null` for compliant
14/// session-turn JSON, so this fills in an empty summary object that downstream
15/// rendering already maps to `No changes`.
16pub 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
31/// Parses one raw assistant message strictly as protocol payload.
32///
33/// The final assistant payload must match [`AgentResponse`] and contain at
34/// least one recognized protocol key (`answer`, `questions`, or `summary`).
35///
36/// When a provider prepends stray prose before the final schema object, this
37/// still recovers the trailing protocol payload as long as nothing except
38/// whitespace follows the JSON object. As a further resilience fallback,
39/// markdown code fences wrapping the JSON object are stripped before parsing
40/// when neither direct parsing nor trailing-object recovery succeeds. An
41/// additional fallback extracts JSON from an embedded code fence preceded by
42/// prose text (e.g., commentary followed by a fenced JSON block).
43/// Top-level fields may rely on the wire type's defaults.
44///
45/// # Errors
46/// Returns [`AgentResponseParseError`] when no valid protocol payload is found.
47pub 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
98/// Builds one multi-line debug report for a protocol parsing failure.
99///
100/// The report summarizes response sizing, markdown wrapping, JSON parse
101/// diagnostics, and any visible top-level keys so schema mismatch errors
102/// include enough context to diagnose malformed provider output quickly.
103pub fn format_protocol_parse_debug_details(raw: &str) -> String {
104    let trimmed = raw.trim();
105    let mut detail_lines = vec![
106        format!("response_len: {} chars", raw.chars().count()),
107        format!("response_lines: {}", raw.lines().count()),
108        format!("trimmed_len: {} chars", trimmed.chars().count()),
109        format!(
110            "wrapped_in_markdown_fence: {}",
111            strip_markdown_code_fence(trimmed).is_some()
112        ),
113    ];
114
115    if trimmed.is_empty() {
116        return detail_lines.join("\n");
117    }
118
119    push_character_boundary_debug_lines(&mut detail_lines, trimmed);
120    push_json_debug_lines(&mut detail_lines, "direct_json", trimmed);
121
122    if let Some(inner) = strip_markdown_code_fence(trimmed) {
123        detail_lines.push(format!(
124            "code_fence_inner_len: {} chars",
125            inner.chars().count()
126        ));
127        push_json_debug_lines(&mut detail_lines, "code_fence_json", inner);
128    }
129
130    if let Some(embedded_value) = find_last_embedded_json_value(trimmed) {
131        detail_lines.push("embedded_json_candidate: found".to_string());
132        push_json_value_debug_lines(&mut detail_lines, "embedded_json", &embedded_value);
133    } else {
134        detail_lines.push("embedded_json_candidate: none".to_string());
135    }
136
137    detail_lines.push(format!(
138        "response_preview:\n{}",
139        format_debug_preview(trimmed, 240)
140    ));
141
142    detail_lines.join("\n")
143}
144
145/// Attempts to parse one schema-driven structured JSON response.
146///
147/// The raw text must parse as a JSON object containing at least one
148/// recognized protocol key (`answer`, `questions`, or `summary`). Returns
149/// `None` when parsing fails or no recognized keys are present.
150fn parse_structured_json_response(raw: &str) -> Option<AgentResponse> {
151    parse_structured_json_response_with_reason(raw).ok()
152}
153
154/// Parses one schema-driven JSON response and returns the structured error
155/// detail when the payload cannot be parsed or validated.
156fn 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
179/// Top-level keys the protocol recognizes in a structured response payload.
180const PROTOCOL_KEYS: &[&str] = &["answer", "questions", "summary"];
181
182/// Returns whether a parsed JSON value is an object containing at least one
183/// recognized protocol key.
184fn 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
190/// Parses one full protocol payload and then falls back to recovering a
191/// trailing schema object from wrapped provider output.
192fn 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
196/// Recovers one trailing protocol payload from provider output that starts
197/// with extra prose before the final JSON object.
198///
199/// This intentionally keeps trailing text strict: once a candidate JSON object
200/// parses successfully, only whitespace may remain after it. The candidate
201/// must also contain at least one recognized protocol key.
202fn 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
211/// Strips a leading markdown code fence and trailing closing fence from a
212/// trimmed response payload, returning the inner content if the pattern
213/// matches.
214fn 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
227/// Extracts the inner content from the last markdown code fence embedded in a
228/// response that also contains surrounding prose text.
229///
230/// Handles the pattern where a provider prepends commentary before a fenced
231/// JSON payload (e.g., `"Some explanation\n` ` ```json\n{...}\n``` ` `"`).
232fn 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
250/// Finds the last JSON object embedded in a response when it consumes the full
251/// trailing suffix except for whitespace.
252fn 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
270/// Appends stable character-boundary diagnostics for one trimmed response.
271fn 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
281/// Appends either JSON parse failure details or top-level JSON shape details.
282fn 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
300/// Appends the top-level JSON type and protocol-key visibility for one value.
301fn 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
331/// Returns one stable label for a top-level JSON value type.
332fn 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
343/// Returns one stable label for the serde JSON error category.
344fn 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
353/// Formats one debug list as a comma-separated string or `(none)`.
354fn format_debug_list(items: &[String]) -> String {
355    if items.is_empty() {
356        return "(none)".to_string();
357    }
358
359    items.join(", ")
360}
361
362/// Truncates a debug preview while preserving the original leading content.
363fn format_debug_preview(raw: &str, max_chars: usize) -> String {
364    let preview = raw.chars().take(max_chars).collect::<String>();
365    let total_chars = raw.chars().count();
366    if total_chars <= max_chars {
367        return preview;
368    }
369
370    format!(
371        "{preview}\n... [truncated {} chars]",
372        total_chars - max_chars
373    )
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    /// Strict parsing accepts a complete schema payload.
382    fn test_parse_agent_response_strict_structured_json_payload() {
383        // Arrange
384        let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
385
386        // Act
387        let response = parse_agent_response_strict(raw);
388
389        // Assert
390        assert_eq!(
391            response.expect("response should parse").answer,
392            "Here is my analysis."
393        );
394    }
395
396    #[test]
397    /// Strict parsing accepts summary-only payloads that still match the
398    /// protocol shape.
399    fn test_parse_agent_response_strict_summary_only_payload() {
400        // Arrange
401        let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
402
403        // Act
404        let response = parse_agent_response_strict(raw);
405
406        // Assert
407        assert_eq!(
408            response.expect("response should parse").summary,
409            Some(AgentResponseSummary {
410                session: "Current diff summary".to_string(),
411                turn: "Turn summary".to_string(),
412            })
413        );
414    }
415
416    #[test]
417    /// Strict parsing recovers a trailing protocol payload when a provider
418    /// prepends extra prose before the final JSON object.
419    fn test_parse_agent_response_strict_recovers_wrapped_text() {
420        // Arrange
421        let raw = concat!(
422            "Some wrapper text\n",
423            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
424        );
425
426        // Act
427        let response = parse_agent_response_strict(raw);
428
429        // Assert
430        assert_eq!(
431            response.expect("response should parse"),
432            AgentResponse::plain("Recovered payload")
433        );
434    }
435
436    #[test]
437    /// Strict parsing rejects plain text that contains no protocol payload.
438    fn test_parse_agent_response_strict_rejects_plain_text() {
439        // Arrange
440        let raw = "plain text";
441
442        // Act
443        let response = parse_agent_response_strict(raw);
444
445        // Assert
446        assert!(response.is_err());
447    }
448
449    #[test]
450    /// Debug formatting reports JSON parser location details for plain-text
451    /// responses that never produced protocol JSON.
452    fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
453        // Arrange
454        let raw = "plain text";
455
456        // Act
457        let details = format_protocol_parse_debug_details(raw);
458
459        // Assert
460        assert!(details.contains("response_len: 10 chars"));
461        assert!(details.contains("first_non_whitespace_char: 'p'"));
462        assert!(details.contains("direct_json_error_category: syntax"));
463        assert!(details.contains("direct_json_error_location: line 1, column 1"));
464        assert!(details.contains("embedded_json_candidate: none"));
465    }
466
467    #[test]
468    /// Debug formatting reports visible top-level keys when the response is
469    /// valid JSON but does not include any protocol fields.
470    fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
471        // Arrange
472        let raw = r#"{"message":"not the expected shape"}"#;
473
474        // Act
475        let details = format_protocol_parse_debug_details(raw);
476
477        // Assert
478        assert!(details.contains("direct_json_type: object"));
479        assert!(details.contains("direct_json_keys: message"));
480        assert!(details.contains("direct_json_recognized_protocol_keys: (none)"));
481        assert!(details.contains("direct_json_missing_protocol_keys: answer, questions, summary"));
482    }
483
484    #[test]
485    /// Fills in empty summaries for session turns.
486    fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
487        // Arrange
488        let response = AgentResponse::plain("done");
489
490        // Act
491        let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
492
493        // Assert
494        assert_eq!(
495            normalized.summary,
496            Some(AgentResponseSummary {
497                session: String::new(),
498                turn: String::new(),
499            })
500        );
501    }
502
503    #[test]
504    /// Leaves one-shot prompt summaries unset.
505    fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
506        // Arrange
507        let response = AgentResponse::plain("done");
508
509        // Act
510        let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
511
512        // Assert
513        assert_eq!(normalized.summary, None);
514    }
515
516    #[test]
517    /// Strict parsing rejects JSON objects with only unrecognized fields
518    /// because at least one protocol key must be present.
519    fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
520        // Arrange
521        let raw = r#"{"message":"not the expected shape"}"#;
522
523        // Act
524        let response = parse_agent_response_strict(raw);
525
526        // Assert
527        assert!(response.is_err());
528    }
529
530    #[test]
531    /// Strict parsing rejects an empty JSON object because no recognized
532    /// protocol key is present.
533    fn test_parse_agent_response_strict_rejects_empty_json_object() {
534        // Arrange
535        let raw = "{}";
536
537        // Act
538        let response = parse_agent_response_strict(raw);
539
540        // Assert
541        assert!(response.is_err());
542    }
543
544    #[test]
545    /// Strict parsing strips code fences and recovers the inner JSON payload.
546    fn test_parse_agent_response_strict_strips_code_fenced_payload() {
547        // Arrange
548        let raw = concat!(
549            "```json\n",
550            r#"{"answer":"Need details.","questions":[],"summary":null}"#,
551            "\n```"
552        );
553
554        // Act
555        let response = parse_agent_response_strict(raw);
556
557        // Assert
558        assert_eq!(
559            response.expect("response should parse").answer,
560            "Need details."
561        );
562    }
563
564    #[test]
565    /// Strict parsing strips code fences even when leading/trailing whitespace
566    /// surrounds the fenced block.
567    fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
568        // Arrange
569        let raw = concat!(
570            "\n\n```json\n",
571            r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
572            "\n```\n"
573        );
574
575        // Act
576        let response = parse_agent_response_strict(raw);
577
578        // Assert
579        assert_eq!(
580            response.expect("response should parse").answer,
581            "Recovered."
582        );
583    }
584
585    #[test]
586    /// Strict parsing strips plain code fences without a language tag.
587    fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
588        // Arrange
589        let raw = concat!(
590            "```\n",
591            r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
592            "\n```"
593        );
594
595        // Act
596        let response = parse_agent_response_strict(raw);
597
598        // Assert
599        assert_eq!(
600            response.expect("response should parse").answer,
601            "Plain fence."
602        );
603    }
604
605    #[test]
606    /// Strict parsing tolerates extra top-level fields that providers may add
607    /// beyond the protocol schema.
608    fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
609        // Arrange
610        let raw =
611            r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
612
613        // Act
614        let response = parse_agent_response_strict(raw);
615
616        // Assert
617        assert_eq!(response.expect("response should parse").answer, "Hello.");
618    }
619
620    #[test]
621    /// Strict parsing tolerates extra fields inside nested summary objects.
622    fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
623        // Arrange
624        let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
625
626        // Act
627        let response = parse_agent_response_strict(raw);
628
629        // Assert
630        let response = response.expect("response should parse");
631        assert_eq!(response.answer, "Done.");
632        assert_eq!(
633            response.summary,
634            Some(AgentResponseSummary {
635                session: "Bug fix session".to_string(),
636                turn: "Fixed bug".to_string(),
637            })
638        );
639    }
640
641    #[test]
642    /// Strict parsing tolerates extra fields inside nested question objects.
643    fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
644        // Arrange
645        let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
646
647        // Act
648        let response = parse_agent_response_strict(raw);
649
650        // Assert
651        let questions = response.expect("response should parse").question_items();
652        assert_eq!(questions.len(), 1);
653        assert_eq!(questions[0].text, "Which approach?");
654    }
655
656    #[test]
657    /// Parser accepts a payload with `questions` but no `answer` key,
658    /// exercising the documented asymmetry where the parser is lenient
659    /// (any recognized key suffices) while the prompt schema requires
660    /// `answer`.
661    fn test_parse_agent_response_strict_accepts_questions_without_answer() {
662        // Arrange
663        let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
664
665        // Act
666        let response = parse_agent_response_strict(raw);
667
668        // Assert
669        let response = response.expect("parser should accept questions-only payload");
670        assert_eq!(response.answer, "");
671        assert_eq!(response.question_items().len(), 1);
672    }
673
674    #[test]
675    /// Parser accepts a payload with `summary` but no `answer` key,
676    /// exercising the documented asymmetry where the parser is lenient
677    /// (any recognized key suffices) while the prompt schema requires
678    /// `answer`.
679    fn test_parse_agent_response_strict_accepts_summary_without_answer() {
680        // Arrange
681        let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
682
683        // Act
684        let response = parse_agent_response_strict(raw);
685
686        // Assert
687        let response = response.expect("parser should accept summary-only payload");
688        assert_eq!(response.answer, "");
689        assert_eq!(
690            response.summary,
691            Some(AgentResponseSummary {
692                session: "Bug fix session".to_string(),
693                turn: "Fixed bug".to_string(),
694            })
695        );
696    }
697
698    #[test]
699    /// Recovery path skips non-protocol JSON objects embedded in prose when
700    /// they contain no recognized protocol keys.
701    fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
702        // Arrange
703        let raw = concat!(
704            "Some wrapper text\n",
705            r#"{"reasoning":"internal thought","confidence":0.9}"#
706        );
707
708        // Act
709        let response = parse_agent_response_strict(raw);
710
711        // Assert
712        assert!(response.is_err());
713    }
714
715    #[test]
716    /// Strict parsing still rejects trailing wrapper text after a recovered
717    /// schema object.
718    fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
719        // Arrange
720        let raw = concat!(
721            "Some wrapper text\n",
722            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
723            "\ntrailing wrapper text"
724        );
725
726        // Act
727        let response = parse_agent_response_strict(raw);
728
729        // Assert
730        assert!(response.is_err());
731    }
732
733    #[test]
734    /// Strict parsing recovers protocol JSON from an embedded code fence
735    /// preceded by prose text.
736    fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
737        // Arrange
738        let raw = concat!(
739            "The commit message looks good. Let me refine it.\n\n",
740            "```json\n",
741            r#"{"answer":"Refined commit message","questions":[],"summary":null}"#,
742            "\n```"
743        );
744
745        // Act
746        let response = parse_agent_response_strict(raw);
747
748        // Assert
749        assert_eq!(
750            response.expect("response should parse").answer,
751            "Refined commit message"
752        );
753    }
754}