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.
103///
104/// Every line is *derived* metadata: sizes, parser locations, and key names.
105/// No provider payload text is reproduced. Turn errors are rendered into the
106/// session transcript, so quoting the payload here would print raw provider
107/// output into the chat.
108pub 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
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#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    /// The debug report describes the payload without reproducing any of it,
368    /// so a turn error cannot print raw provider output into the transcript.
369    fn test_format_protocol_parse_debug_details_never_quotes_payload() {
370        // Arrange
371        let raw = format!("{} SECRETTAIL", "payload-filler ".repeat(40));
372
373        // Act
374        let details = format_protocol_parse_debug_details(&raw);
375
376        // Assert
377        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    /// Strict parsing accepts a complete schema payload.
385    fn test_parse_agent_response_strict_structured_json_payload() {
386        // Arrange
387        let raw = r#"{"answer":"Here is my analysis.","questions":[],"summary":null}"#;
388
389        // Act
390        let response = parse_agent_response_strict(raw);
391
392        // Assert
393        assert_eq!(
394            response.expect("response should parse").answer,
395            "Here is my analysis."
396        );
397    }
398
399    #[test]
400    /// Strict parsing accepts summary-only payloads that still match the
401    /// protocol shape.
402    fn test_parse_agent_response_strict_summary_only_payload() {
403        // Arrange
404        let raw = r#"{"summary":{"session":"Current diff summary","turn":"Turn summary"}}"#;
405
406        // Act
407        let response = parse_agent_response_strict(raw);
408
409        // Assert
410        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    /// Strict parsing recovers a trailing protocol payload when a provider
421    /// prepends extra prose before the final JSON object.
422    fn test_parse_agent_response_strict_recovers_wrapped_text() {
423        // Arrange
424        let raw = concat!(
425            "Some wrapper text\n",
426            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#
427        );
428
429        // Act
430        let response = parse_agent_response_strict(raw);
431
432        // Assert
433        assert_eq!(
434            response.expect("response should parse"),
435            AgentResponse::plain("Recovered payload")
436        );
437    }
438
439    #[test]
440    /// Strict parsing rejects plain text that contains no protocol payload.
441    fn test_parse_agent_response_strict_rejects_plain_text() {
442        // Arrange
443        let raw = "plain text";
444
445        // Act
446        let response = parse_agent_response_strict(raw);
447
448        // Assert
449        assert!(response.is_err());
450    }
451
452    #[test]
453    /// Debug formatting reports JSON parser location details for plain-text
454    /// responses that never produced protocol JSON.
455    fn test_format_protocol_parse_debug_details_reports_plain_text_json_error() {
456        // Arrange
457        let raw = "plain text";
458
459        // Act
460        let details = format_protocol_parse_debug_details(raw);
461
462        // Assert
463        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    /// Debug formatting reports visible top-level keys when the response is
472    /// valid JSON but does not include any protocol fields.
473    fn test_format_protocol_parse_debug_details_reports_unrecognized_json_keys() {
474        // Arrange
475        let raw = r#"{"message":"not the expected shape"}"#;
476
477        // Act
478        let details = format_protocol_parse_debug_details(raw);
479
480        // Assert
481        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    /// Fills in empty summaries for session turns.
489    fn test_normalize_turn_response_fills_missing_summary_for_session_turn() {
490        // Arrange
491        let response = AgentResponse::plain("done");
492
493        // Act
494        let normalized = normalize_turn_response(response, ProtocolRequestProfile::SessionTurn);
495
496        // Assert
497        assert_eq!(
498            normalized.summary,
499            Some(AgentResponseSummary {
500                session: String::new(),
501                turn: String::new(),
502            })
503        );
504    }
505
506    #[test]
507    /// Leaves one-shot prompt summaries unset.
508    fn test_normalize_turn_response_keeps_missing_summary_for_utility_prompt() {
509        // Arrange
510        let response = AgentResponse::plain("done");
511
512        // Act
513        let normalized = normalize_turn_response(response, ProtocolRequestProfile::UtilityPrompt);
514
515        // Assert
516        assert_eq!(normalized.summary, None);
517    }
518
519    #[test]
520    /// Strict parsing rejects JSON objects with only unrecognized fields
521    /// because at least one protocol key must be present.
522    fn test_parse_agent_response_strict_rejects_unrecognized_only_fields() {
523        // Arrange
524        let raw = r#"{"message":"not the expected shape"}"#;
525
526        // Act
527        let response = parse_agent_response_strict(raw);
528
529        // Assert
530        assert!(response.is_err());
531    }
532
533    #[test]
534    /// Strict parsing rejects an empty JSON object because no recognized
535    /// protocol key is present.
536    fn test_parse_agent_response_strict_rejects_empty_json_object() {
537        // Arrange
538        let raw = "{}";
539
540        // Act
541        let response = parse_agent_response_strict(raw);
542
543        // Assert
544        assert!(response.is_err());
545    }
546
547    #[test]
548    /// Strict parsing strips code fences and recovers the inner JSON payload.
549    fn test_parse_agent_response_strict_strips_code_fenced_payload() {
550        // Arrange
551        let raw = concat!(
552            "```json\n",
553            r#"{"answer":"Need details.","questions":[],"summary":null}"#,
554            "\n```"
555        );
556
557        // Act
558        let response = parse_agent_response_strict(raw);
559
560        // Assert
561        assert_eq!(
562            response.expect("response should parse").answer,
563            "Need details."
564        );
565    }
566
567    #[test]
568    /// Strict parsing strips code fences even when leading/trailing whitespace
569    /// surrounds the fenced block.
570    fn test_parse_agent_response_strict_strips_code_fenced_payload_with_whitespace() {
571        // Arrange
572        let raw = concat!(
573            "\n\n```json\n",
574            r#"{"answer":"Recovered.","questions":[],"summary":null}"#,
575            "\n```\n"
576        );
577
578        // Act
579        let response = parse_agent_response_strict(raw);
580
581        // Assert
582        assert_eq!(
583            response.expect("response should parse").answer,
584            "Recovered."
585        );
586    }
587
588    #[test]
589    /// Strict parsing strips plain code fences without a language tag.
590    fn test_parse_agent_response_strict_strips_plain_code_fenced_payload() {
591        // Arrange
592        let raw = concat!(
593            "```\n",
594            r#"{"answer":"Plain fence.","questions":[],"summary":null}"#,
595            "\n```"
596        );
597
598        // Act
599        let response = parse_agent_response_strict(raw);
600
601        // Assert
602        assert_eq!(
603            response.expect("response should parse").answer,
604            "Plain fence."
605        );
606    }
607
608    #[test]
609    /// Strict parsing tolerates extra top-level fields that providers may add
610    /// beyond the protocol schema.
611    fn test_parse_agent_response_strict_tolerates_extra_top_level_fields() {
612        // Arrange
613        let raw =
614            r#"{"answer":"Hello.","questions":[],"summary":null,"reasoning":"internal thought"}"#;
615
616        // Act
617        let response = parse_agent_response_strict(raw);
618
619        // Assert
620        assert_eq!(response.expect("response should parse").answer, "Hello.");
621    }
622
623    #[test]
624    /// Strict parsing tolerates extra fields inside nested summary objects.
625    fn test_parse_agent_response_strict_tolerates_extra_summary_fields() {
626        // Arrange
627        let raw = r#"{"answer":"Done.","questions":[],"summary":{"turn":"Fixed bug","session":"Bug fix session","confidence":"high"}}"#;
628
629        // Act
630        let response = parse_agent_response_strict(raw);
631
632        // Assert
633        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    /// Strict parsing tolerates extra fields inside nested question objects.
646    fn test_parse_agent_response_strict_tolerates_extra_question_fields() {
647        // Arrange
648        let raw = r#"{"answer":"","questions":[{"text":"Which approach?","options":["A","B"],"priority":"high"}],"summary":null}"#;
649
650        // Act
651        let response = parse_agent_response_strict(raw);
652
653        // Assert
654        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    /// Parser accepts a payload with `questions` but no `answer` key,
661    /// exercising the documented asymmetry where the parser is lenient
662    /// (any recognized key suffices) while the prompt schema requires
663    /// `answer`.
664    fn test_parse_agent_response_strict_accepts_questions_without_answer() {
665        // Arrange
666        let raw = r#"{"questions":[{"text":"Which approach?"}]}"#;
667
668        // Act
669        let response = parse_agent_response_strict(raw);
670
671        // Assert
672        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    /// Parser accepts a payload with `summary` but no `answer` key,
679    /// exercising the documented asymmetry where the parser is lenient
680    /// (any recognized key suffices) while the prompt schema requires
681    /// `answer`.
682    fn test_parse_agent_response_strict_accepts_summary_without_answer() {
683        // Arrange
684        let raw = r#"{"summary":{"turn":"Fixed bug","session":"Bug fix session"}}"#;
685
686        // Act
687        let response = parse_agent_response_strict(raw);
688
689        // Assert
690        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    /// Recovery path skips non-protocol JSON objects embedded in prose when
703    /// they contain no recognized protocol keys.
704    fn test_parse_agent_response_strict_rejects_wrapped_non_protocol_json() {
705        // Arrange
706        let raw = concat!(
707            "Some wrapper text\n",
708            r#"{"reasoning":"internal thought","confidence":0.9}"#
709        );
710
711        // Act
712        let response = parse_agent_response_strict(raw);
713
714        // Assert
715        assert!(response.is_err());
716    }
717
718    #[test]
719    /// Strict parsing still rejects trailing wrapper text after a recovered
720    /// schema object.
721    fn test_parse_agent_response_strict_rejects_trailing_wrapper_after_payload() {
722        // Arrange
723        let raw = concat!(
724            "Some wrapper text\n",
725            r#"{"answer":"Recovered payload","questions":[],"summary":null}"#,
726            "\ntrailing wrapper text"
727        );
728
729        // Act
730        let response = parse_agent_response_strict(raw);
731
732        // Assert
733        assert!(response.is_err());
734    }
735
736    #[test]
737    /// Strict parsing recovers protocol JSON from an embedded code fence
738    /// preceded by prose text.
739    fn test_parse_agent_response_strict_recovers_embedded_code_fence_in_prose() {
740        // Arrange
741        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        // Act
749        let response = parse_agent_response_strict(raw);
750
751        // Assert
752        assert_eq!(
753            response.expect("response should parse").answer,
754            "Refined commit message"
755        );
756    }
757}