Skip to main content

orch/
parser.rs

1//! Agent response parser — normalizes JSON output from different agents.
2//!
3//! Each agent (Claude, Codex, OpenCode) returns a different JSON shape.
4//! This module parses them all into a common `AgentResponse` struct.
5//! Replaces `scripts/parse_response.sh` + `jq` pipelines.
6
7use anyhow::Context;
8use once_cell::sync::Lazy;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::io::Read;
12
13static TRAILING_COMMA_RE: Lazy<Regex> =
14    Lazy::new(|| Regex::new(r",(\s*[}\]])").expect("trailing comma regex is valid"));
15
16/// Normalized agent response.
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct AgentResponse {
19    pub status: String,
20    #[serde(default)]
21    pub summary: String,
22    #[serde(default)]
23    pub accomplished: Vec<String>,
24    #[serde(default)]
25    pub remaining: Vec<String>,
26    #[serde(default, alias = "files_changed", alias = "files_modified")]
27    pub files: Vec<String>,
28    #[serde(default)]
29    pub error: Option<String>,
30    /// Input token count (if available from agent).
31    #[serde(default)]
32    pub input_tokens: Option<u64>,
33    /// Output token count (if available from agent).
34    #[serde(default)]
35    pub output_tokens: Option<u64>,
36    /// Key learnings from this attempt (for memory persistence across retries).
37    #[serde(default)]
38    pub learnings: Vec<String>,
39    /// Subtask delegations — agent requests child tasks to be created.
40    #[serde(default)]
41    pub delegations: Vec<Delegation>,
42}
43
44/// A subtask delegation requested by an agent.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Delegation {
47    pub title: String,
48    pub body: String,
49    #[serde(default)]
50    pub labels: Vec<String>,
51}
52
53/// Parse an agent response from a file path (or stdin if "-").
54pub fn parse_and_print(path: &str) -> anyhow::Result<()> {
55    let content = if path == "-" {
56        let mut buf = String::new();
57        std::io::stdin()
58            .read_to_string(&mut buf)
59            .context("reading stdin")?;
60        buf
61    } else {
62        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
63    };
64
65    let response = parse(&content)?;
66    println!("{}", serde_json::to_string_pretty(&response)?);
67    Ok(())
68}
69
70/// Known canonical statuses - used to prefer better candidates.
71/// NOTE: Does NOT include aliases like "ready_for_review" - those are
72/// normalized to canonical forms in normalize_status().
73fn status_is_known(status: &str) -> bool {
74    matches!(
75        status,
76        "new"
77            | "routed"
78            | "in_progress"
79            | "done"
80            | "completed"
81            | "ok"
82            | "success"
83            | "running"
84            | "blocked"
85            | "error"
86            | "failed"
87            | "in_review"
88            | "reviewing"
89            | "needs_review"
90            | "pending_review"
91    )
92}
93
94/// Parse raw agent output into a normalized response.
95pub fn parse(raw: &str) -> anyhow::Result<AgentResponse> {
96    let mut last_err: Option<anyhow::Error> = None;
97    let mut saw_jsonish_candidate = false;
98    // Pair: (parsed response, raw candidate string) so we can check the raw object
99    // for substantive fields that map_generic_response handles but that direct
100    // deserialization into AgentResponse ignores (e.g. "output", "message").
101    let mut best_candidate: Option<(AgentResponse, String)> = None;
102
103    for candidate in json_candidates(raw) {
104        match parse_candidate(&candidate) {
105            Ok(mut resp) => {
106                resp = normalize_status(resp);
107                // If status is known, return immediately (good candidate found).
108                if status_is_known(&resp.status) {
109                    return Ok(resp);
110                }
111                // Non-canonical status - remember it but keep looking for better.
112                if best_candidate.is_none() {
113                    best_candidate = Some((resp, candidate));
114                }
115            }
116            Err(err) => {
117                if err.to_string() != "invalid agent response candidate" {
118                    saw_jsonish_candidate = true;
119                    last_err = Some(err);
120                }
121            }
122        }
123    }
124
125    // If we found a candidate with non-canonical status but no known-status
126    // candidate was found, use the best one we have — but only if it has at
127    // least one substantive orch control field. Domain JSON records (career
128    // pipeline entries, trading alerts, etc.) have a `status` field that can
129    // be deserialized into AgentResponse, but they carry no orch fields like
130    // `summary`, `accomplished`, `files`, etc. Returning them would silently
131    // promote a domain value (e.g. "inbound-not-submitted") as the task status.
132    if let Some((resp, raw_candidate)) = best_candidate {
133        if has_substantive_fields(&raw_candidate) {
134            return Ok(resp);
135        }
136    }
137
138    if saw_jsonish_candidate {
139        if let Some(err) = last_err {
140            return Err(err);
141        }
142    }
143
144    if let Some(err) = last_err {
145        return Err(err);
146    }
147
148    anyhow::bail!(
149        "agent output is not valid JSON or a supported JSON wrapper: {}",
150        raw.trim()
151    )
152}
153
154/// Map known non-canonical agent statuses to their canonical equivalents.
155///
156/// Agents sometimes return statuses like `"ok"`, `"success"`, or `"completed"`
157/// that are semantically equivalent to `"done"`. Normalizing them here ensures
158/// that `classify_run_outcome` correctly classifies the run as success and
159/// prevents opaque `"no error info available"` failures when an agent returns
160/// a valid but non-canonical status.
161///
162/// Unknown statuses that read as completions normalize to done via the shared
163/// heuristic in `status_heuristics`; the rest stay as-is for the audit trail.
164fn normalize_status(mut resp: AgentResponse) -> AgentResponse {
165    let normalized = resp.status.to_ascii_lowercase();
166    if normalized.starts_with("pushed to ") || normalized.starts_with("pushed ") {
167        resp.status = "done".to_string();
168        return resp;
169    }
170
171    resp.status = match normalized.as_str() {
172        // Canonical completion statuses.
173        // Some agents return `complete` or `no_changes_needed` to indicate
174        // a successful run with no further edits required — treat them as
175        // `done` so the run is classified as successful.
176        "done"
177        | "completed"
178        | "complete"
179        | "skipped"
180        | "skip"
181        | "ok"
182        | "success"
183        | "noop"
184        | "green"
185        | "no_changes_needed"
186        | "no_trades_no_positions"
187        | "no_setups"
188        | "changes_made"
189        | "change_made"
190        | "changes_pushed"
191        | "pushed"
192        | "acknowledged"
193        | "flat"
194        | "no_action"
195        | "no_action_needed"
196        | "missed"
197        | "changes_addressed"
198        | "changes_requested_addressed"
199        | "review_addressed"
200        | "already_finalized_in_attempt_1"
201        | "alert"
202        | "alerts"
203        | "alerts_sent"
204        | "not_configured"
205        | "healthy"
206        | "alerts_fired"
207        | "verified"
208        | "fixed" => "done".to_string(),
209        // Canonical progress statuses.
210        // `partial` is used by some models to indicate partial progress —
211        // treat it as in_progress so the task remains open for follow-up.
212        "in_progress" | "running" | "partial" | "waiting" => "in_progress".to_string(),
213        "in_review" | "reviewing" => "in_review".to_string(),
214        "needs_review" | "pending_review" | "ready_for_review" => "needs_review".to_string(),
215        // Canonical error statuses.
216        "blocked" | "error" | "failed" | "failure" => "blocked".to_string(),
217        // Passthrough: known canonical statuses (already canonical, keep as-is).
218        s if s == "new"
219            || s == "routed"
220            || s == "in_progress"
221            || s == "done"
222            || s == "blocked"
223            || s == "in_review"
224            || s == "needs_review" =>
225        {
226            s.to_string()
227        }
228        // Unknown status reading as completion, done if the response carries orch
229        // fields — domain records without them stay non-canonical and get rejected (#3336)
230        _ if !resp.summary.is_empty()
231            || !resp.accomplished.is_empty()
232            || !resp.files.is_empty()
233            || resp.error.is_some() =>
234        {
235            if crate::status_heuristics::status_looks_like_descriptive_completion(&normalized) {
236                "done".to_string()
237            } else {
238                resp.status.clone()
239            }
240        }
241        _ => resp.status.clone(),
242    };
243    resp
244}
245
246/// Extract the first JSON code block from markdown.
247fn extract_json_block(text: &str) -> Option<String> {
248    let start = text.find("```json")?;
249    let fence_end = start + "```json".len();
250    let remainder = &text[fence_end..];
251
252    // Find key positions after the opening fence.
253    let newline_pos = remainder.find('\n');
254    let json_start_pos = remainder.find(['{', '[']);
255    let first_nl = newline_pos.unwrap_or(usize::MAX);
256
257    // Decide fenced vs inline:
258    // - If a JSON delimiter ('{' or '[') appears before the first newline, treat as inline.
259    // - If there is no newline at all, treat as inline.
260    // - Otherwise, treat as a standard fenced block.
261    let is_inline = json_start_pos.is_some_and(|js| js < first_nl) || newline_pos.is_none();
262
263    if is_inline {
264        // Inline: closing fence may appear anywhere on the same logical line.
265        // Using find("```") here is safe because the JSON start already appeared
266        // before any newline, so the closing fence is on the same line too.
267        let closing_fence_pos = remainder.find("```")?;
268        let content_start = remainder
269            .char_indices()
270            .find(|(_, ch)| !ch.is_whitespace())
271            .map_or(fence_end, |(idx, _)| fence_end + idx);
272        let end = fence_end + closing_fence_pos;
273        if content_start > end {
274            return None;
275        }
276        Some(text[content_start..end].to_string())
277    } else {
278        // Fenced block: content begins after the first newline.
279        // The closing fence must be anchored to a line boundary (\n```) to avoid
280        // false positives from triple-backticks inside JSON string values.
281        let nl = newline_pos?;
282        let content_start = fence_end + nl + 1;
283        let content = &text[content_start..];
284        let closing_pos = find_closing_fence_at_line_boundary(content)?;
285        Some(text[content_start..content_start + closing_pos].to_string())
286    }
287}
288
289/// Find the position (within `content`) where a ``` closing fence starts, anchored to a
290/// line boundary.  Returns the byte offset of the leading ``` so that `content[..pos]`
291/// is everything before the closing fence (including the preceding newline).
292fn find_closing_fence_at_line_boundary(content: &str) -> Option<usize> {
293    // Edge case: content itself begins with the closing fence (no preceding content).
294    if content.starts_with("```") {
295        return Some(0);
296    }
297    // Scan for \n``` pattern — the closing fence must start at the beginning of a line.
298    let mut search_from = 0;
299    while let Some(nl_idx) = content[search_from..].find('\n') {
300        let after_nl = search_from + nl_idx + 1;
301        if content[after_nl..].starts_with("```") {
302            // Return the position of the ``` (after the \n).
303            // The content slice up to this point includes the \n, matching Markdown semantics.
304            return Some(after_nl);
305        }
306        search_from = after_nl;
307    }
308    None
309}
310
311/// Returns true if the raw JSON candidate has at least one substantive orch control field.
312///
313/// Operates on the raw string (not the deserialized `AgentResponse`) so that fields like
314/// `output` and `message` — which `map_generic_response` maps to `summary` but which are
315/// not struct fields on `AgentResponse` and are silently ignored by direct deserialization —
316/// are correctly treated as substantive. This prevents domain JSON records (career pipeline
317/// entries, trading alerts, etc.) whose only orch-like field is `status` from being promoted
318/// as the task status.
319fn has_substantive_fields(raw: &str) -> bool {
320    if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(raw) {
321        return SUBSTANTIVE_RESPONSE_FIELDS
322            .iter()
323            .any(|key| obj.contains_key(*key))
324            || obj.contains_key("error");
325    }
326    false
327}
328
329/// Fields that indicate a JSON blob is an AgentResponse (higher score = better match).
330const AGENT_RESPONSE_FIELDS: &[&str] = &[
331    "\"status\"",
332    "\"summary\"",
333    "\"accomplished\"",
334    "\"remaining\"",
335    "\"files\"",
336    "\"learnings\"",
337    "\"delegations\"",
338    "\"error\"",
339];
340
341fn agent_response_score(blob: &str) -> usize {
342    AGENT_RESPONSE_FIELDS
343        .iter()
344        .filter(|&&f| blob.contains(f))
345        .count()
346}
347
348fn json_candidates(raw: &str) -> Vec<String> {
349    let mut candidates = Vec::new();
350
351    if let Some(block) = extract_json_block(raw) {
352        candidates.push(block);
353    }
354
355    // Collect ALL balanced JSON blobs and sort by how much they look like an
356    // AgentResponse, so the real response is tried before telemetry blobs.
357    let mut all_json = extract_all_balanced_json(raw);
358    all_json.sort_by_key(|b| std::cmp::Reverse(agent_response_score(b)));
359
360    for candidate in all_json {
361        if !candidates.iter().any(|existing| existing == &candidate) {
362            candidates.push(candidate);
363        }
364    }
365
366    candidates.push(raw.to_string());
367    candidates
368}
369
370fn parse_candidate(candidate: &str) -> anyhow::Result<AgentResponse> {
371    if let Ok(resp) = serde_json::from_str::<AgentResponse>(candidate) {
372        return Ok(resp);
373    }
374
375    let repaired = repair_json_like(candidate);
376    if repaired != candidate {
377        if let Ok(resp) = serde_json::from_str::<AgentResponse>(&repaired) {
378            return Ok(resp);
379        }
380    }
381
382    if let Ok(val) = serde_json::from_str::<serde_json::Value>(candidate) {
383        return map_generic_response(&val);
384    }
385
386    if repaired != candidate {
387        if let Ok(val) = serde_json::from_str::<serde_json::Value>(&repaired) {
388            return map_generic_response(&val);
389        }
390    }
391
392    anyhow::bail!("invalid agent response candidate")
393}
394
395/// Scan `text` and return every top-level balanced JSON object or array found.
396fn extract_all_balanced_json(text: &str) -> Vec<String> {
397    let mut results = Vec::new();
398    let mut pos = 0;
399
400    while pos < text.len() {
401        // Find the next `{` or `[` starting from `pos`.
402        let Some(start_offset) = text[pos..]
403            .char_indices()
404            .find(|(_, ch)| matches!(ch, '{' | '['))
405            .map(|(idx, _)| idx)
406        else {
407            break;
408        };
409        let start = pos + start_offset;
410
411        let mut stack = Vec::new();
412        let mut in_string = false;
413        let mut escape = false;
414        let mut end: Option<usize> = None;
415
416        for (idx, ch) in text[start..].char_indices() {
417            let abs = start + idx;
418            if in_string {
419                if escape {
420                    escape = false;
421                    continue;
422                }
423                match ch {
424                    '\\' => escape = true,
425                    '"' => in_string = false,
426                    _ => {}
427                }
428                continue;
429            }
430
431            match ch {
432                '"' => in_string = true,
433                '{' | '[' => stack.push(ch),
434                '}' => {
435                    if stack.pop() != Some('{') {
436                        break; // malformed — skip past this `{`
437                    }
438                    if stack.is_empty() {
439                        end = Some(abs);
440                        break;
441                    }
442                }
443                ']' => {
444                    if stack.pop() != Some('[') {
445                        break; // malformed — skip past this `[`
446                    }
447                    if stack.is_empty() {
448                        end = Some(abs);
449                        break;
450                    }
451                }
452                _ => {}
453            }
454        }
455
456        if let Some(end_idx) = end {
457            results.push(text[start..=end_idx].to_string());
458            pos = end_idx + 1;
459        } else {
460            // No closing brace found — skip past the opening character.
461            pos = start + 1;
462        }
463    }
464
465    results
466}
467
468fn repair_json_like(text: &str) -> String {
469    let mut repaired = text.trim().replace(['“', '”'], "\"");
470    repaired = repaired.replace(['‘', '’'], "'");
471    repaired = TRAILING_COMMA_RE.replace_all(&repaired, "$1").into_owned();
472
473    let mut stack = Vec::new();
474    let mut in_string = false;
475    let mut escape = false;
476
477    for ch in repaired.chars() {
478        if in_string {
479            if escape {
480                escape = false;
481                continue;
482            }
483            match ch {
484                '\\' => escape = true,
485                '"' => in_string = false,
486                _ => {}
487            }
488            continue;
489        }
490
491        match ch {
492            '"' => in_string = true,
493            '{' => stack.push('}'),
494            '[' => stack.push(']'),
495            '}' | ']' => {
496                if let Some(expected) = stack.last().copied() {
497                    if expected == ch {
498                        stack.pop();
499                    }
500                }
501            }
502            _ => {}
503        }
504    }
505
506    while let Some(ch) = stack.pop() {
507        repaired.push(ch);
508    }
509
510    repaired
511}
512
513/// Fields that indicate a substantive agent response (not just a status blob).
514/// NOTE: We don't include "output" or "message" here because those are
515/// handled via summary extraction in map_generic_response. This list is for
516/// validating that the payload has meaningful agent response structure beyond
517/// just a status field.
518const SUBSTANTIVE_RESPONSE_FIELDS: &[&str] = &[
519    "summary",
520    "message",
521    "accomplished",
522    "remaining",
523    "files",
524    "files_changed",
525    "files_modified",
526    "learnings",
527    "delegations",
528    "output",
529    "pr_url",
530    "report",
531];
532
533/// Infer a canonical completion status from known non-standard completion envelope shapes.
534///
535/// Claude agents sometimes return JSON without a `status`/`result` field, using
536/// alternative completion indicators instead. This function maps those shapes to
537/// a canonical status so they are not misclassified as `InvalidResponse`.
538///
539/// Recognized patterns (all map to `"done"`):
540/// - `{"success": true, ...}` — explicit success boolean
541/// - `{"type": "task_complete", ...}` — explicit task_complete type string
542/// - `{"tests_passed": true, ...}` — tests-passed completion signal
543fn infer_completion_status(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
544    // success: true
545    if obj.get("success").and_then(|v| v.as_bool()) == Some(true) {
546        return Some("done".to_string());
547    }
548    // type: "task_complete"
549    if obj.get("type").and_then(|v| v.as_str()) == Some("task_complete") {
550        return Some("done".to_string());
551    }
552    // ok: true
553    if obj.get("ok").and_then(|v| v.as_bool()) == Some(true) {
554        return Some("done".to_string());
555    }
556    // tests_passed: true
557    if obj.get("tests_passed").and_then(|v| v.as_bool()) == Some(true) {
558        return Some("done".to_string());
559    }
560    None
561}
562
563/// Map a generic JSON object to AgentResponse.
564///
565/// Requires stricter shape checking: the object must have:
566/// 1. A status/result field (or a recognized completion envelope shape)
567/// 2. AND at least one substantive field OR an error field
568///
569/// This prevents acceptance of non-response JSON blobs (telemetry, tool outputs)
570/// that happen to contain a status field.
571fn map_generic_response(val: &serde_json::Value) -> anyhow::Result<AgentResponse> {
572    let obj = val.as_object().context("expected JSON object")?;
573
574    // Check for status or result field first; fall back to known completion envelopes.
575    let status = obj
576        .get("status")
577        .or_else(|| obj.get("result"))
578        .and_then(|v| v.as_str())
579        .map(String::from)
580        .or_else(|| infer_completion_status(obj))
581        .context("agent response is missing required 'status' or 'result' field")?;
582
583    // Require at least one substantive field OR an error field.
584    // This prevents accepting telemetry/event JSON blobs that happen to have
585    // a status field but are not actual agent responses.
586    let has_substantive_field = SUBSTANTIVE_RESPONSE_FIELDS
587        .iter()
588        .any(|key| obj.contains_key(*key));
589    let has_error = obj.contains_key("error");
590
591    if !has_substantive_field && !has_error {
592        anyhow::bail!("JSON object lacks substantive response fields");
593    }
594
595    let summary = obj
596        .get("summary")
597        .or_else(|| obj.get("message"))
598        .or_else(|| obj.get("output"))
599        .and_then(|v| v.as_str())
600        .unwrap_or("")
601        .to_string();
602
603    let accomplished = extract_string_array(obj.get("accomplished"));
604    let remaining = extract_string_array(obj.get("remaining"));
605    let files = {
606        let f = extract_string_array(obj.get("files"));
607        if !f.is_empty() {
608            f
609        } else {
610            let fc = extract_string_array(obj.get("files_changed"));
611            if !fc.is_empty() {
612                fc
613            } else {
614                extract_string_array(obj.get("files_modified"))
615            }
616        }
617    };
618    let error = obj.get("error").and_then(|v| v.as_str()).map(String::from);
619    let learnings = extract_string_array(obj.get("learnings"));
620
621    // Extract token counts if available
622    let input_tokens = extract_u64(obj.get("input_tokens"))
623        .or_else(|| extract_u64(obj.get("tokens_input")))
624        .or_else(|| extract_usage_tokens(obj.get("usage"), true));
625    let output_tokens = extract_u64(obj.get("output_tokens"))
626        .or_else(|| extract_u64(obj.get("tokens_output")))
627        .or_else(|| extract_usage_tokens(obj.get("usage"), false));
628
629    // Extract delegations. If the `delegations` field is present but malformed,
630    // return an error instead of silently ignoring it so callers can surface
631    // and debug malformed agent output.
632    let delegations = if let Some(v) = obj.get("delegations") {
633        serde_json::from_value::<Vec<Delegation>>(v.clone())
634            .with_context(|| format!("invalid delegations field: {}", v))?
635    } else {
636        Vec::new()
637    };
638
639    Ok(AgentResponse {
640        status,
641        summary,
642        accomplished,
643        remaining,
644        files,
645        error,
646        input_tokens,
647        output_tokens,
648        learnings,
649        delegations,
650    })
651}
652
653/// Extract a u64 from a JSON value.
654fn extract_u64(val: Option<&serde_json::Value>) -> Option<u64> {
655    val.and_then(|v| v.as_u64())
656}
657
658/// Extract tokens from a usage object (common in OpenAI-compatible APIs).
659fn extract_usage_tokens(usage: Option<&serde_json::Value>, is_input: bool) -> Option<u64> {
660    let obj = usage?.as_object()?;
661    let key = if is_input {
662        "input_tokens"
663    } else {
664        "output_tokens"
665    };
666    obj.get(key).and_then(|v| v.as_u64())
667}
668
669fn extract_string_array(val: Option<&serde_json::Value>) -> Vec<String> {
670    val.and_then(|v| v.as_array())
671        .map(|arr| {
672            arr.iter()
673                .filter_map(|v| v.as_str().map(String::from))
674                .collect()
675        })
676        .unwrap_or_default()
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn parse_direct_json() {
685        let input = r#"{"status":"done","summary":"Fixed bug","accomplished":["fixed it"],"remaining":[],"files":["src/main.rs"]}"#;
686        let resp = parse(input).unwrap();
687        assert_eq!(resp.status, "done");
688        assert_eq!(resp.summary, "Fixed bug");
689        assert_eq!(resp.accomplished, vec!["fixed it"]);
690        assert_eq!(resp.files, vec!["src/main.rs"]);
691        assert!(resp.error.is_none());
692    }
693
694    #[test]
695    fn parse_json_in_markdown_block() {
696        let input = r#"Here is the result:
697
698```json
699{"status":"in_progress","summary":"Working on it","accomplished":[],"remaining":["finish tests"],"files":[]}
700```
701
702Done.
703"#;
704        let resp = parse(input).unwrap();
705        assert_eq!(resp.status, "in_progress");
706        assert_eq!(resp.remaining, vec!["finish tests"]);
707    }
708
709    #[test]
710    fn parse_generic_json_with_different_field_names() {
711        let input = r#"{"result":"done","message":"All good","files":["a.rs","b.rs"]}"#;
712        let resp = parse(input).unwrap();
713        assert_eq!(resp.status, "done");
714        assert_eq!(resp.summary, "All good");
715        assert_eq!(resp.files, vec!["a.rs", "b.rs"]);
716    }
717
718    #[test]
719    fn parse_with_error_field() {
720        let input = r#"{"status":"blocked","summary":"Cannot proceed","accomplished":[],"remaining":[],"files":[],"error":"missing dependency"}"#;
721        let resp = parse(input).unwrap();
722        assert_eq!(resp.status, "blocked");
723        assert_eq!(resp.error, Some("missing dependency".to_string()));
724    }
725
726    #[test]
727    fn parse_fallback_raw_text() {
728        let input = "This is just plain text output from the agent.";
729        let err = parse(input).unwrap_err();
730        assert!(err
731            .to_string()
732            .contains("agent output is not valid JSON or a supported JSON wrapper"));
733    }
734
735    #[test]
736    fn parse_malformed_json_block_returns_error() {
737        let input = r#"Here is the result:
738
739```json
740{"status":"done","summary":"missing brace"
741```
742"#;
743        let resp = parse(input).unwrap();
744        assert_eq!(resp.status, "done");
745        assert_eq!(resp.summary, "missing brace");
746    }
747
748    #[test]
749    fn parse_json_with_trailing_comma() {
750        let input =
751            r#"{"status":"done","summary":"Fixed","accomplished":[],"remaining":[],"files":[],}"#;
752        let resp = parse(input).unwrap();
753        assert_eq!(resp.status, "done");
754        assert_eq!(resp.summary, "Fixed");
755    }
756
757    #[test]
758    fn parse_json_embedded_in_commentary() {
759        let input = r#"Done:
760{"status":"in_progress","summary":"working","accomplished":[],"remaining":["tests"],"files":[]}
761Thanks"#;
762        let resp = parse(input).unwrap();
763        assert_eq!(resp.status, "in_progress");
764        assert_eq!(resp.remaining, vec!["tests"]);
765    }
766
767    #[test]
768    fn extract_json_block_from_markdown() {
769        let text = "prefix\n```json\n{\"key\":\"value\"}\n```\nsuffix";
770        let block = extract_json_block(text).unwrap();
771        assert_eq!(block, "{\"key\":\"value\"}\n");
772    }
773
774    #[test]
775    fn extract_json_block_inline_fence() {
776        let text = "prefix\n```json{\"key\":\"value\"}```\nsuffix";
777        let block = extract_json_block(text).unwrap();
778        assert_eq!(block, "{\"key\":\"value\"}");
779    }
780
781    #[test]
782    fn extract_json_block_with_info_string() {
783        let text = "prefix\n```json some-meta\n{\"key\":\"value\"}\n```\nsuffix";
784        let block = extract_json_block(text).unwrap();
785        assert_eq!(block, "{\"key\":\"value\"}\n");
786    }
787
788    #[test]
789    fn extract_json_block_with_info_string_and_whitespace() {
790        let text = "prefix\n```json   some-meta   \n{\"key\":\"value\"}\n```\nsuffix";
791        let block = extract_json_block(text).unwrap();
792        assert_eq!(block, "{\"key\":\"value\"}\n");
793    }
794
795    #[test]
796    fn extract_json_block_inline_with_internal_newline() {
797        // Inline JSON that contains a newline inside the JSON value
798        let text = "prefix\n```json{\"key\":\"value\\nwith newline\"}```\nsuffix";
799        let block = extract_json_block(text).unwrap();
800        assert_eq!(block, r#"{"key":"value\nwith newline"}"#);
801    }
802
803    #[test]
804    fn extract_json_block_inline_with_whitespace_newline() {
805        // Inline JSON that contains a newline as whitespace between key and value
806        let text = "prefix\n```json{\"key\":\n\"value\"}```\nsuffix";
807        let block = extract_json_block(text).unwrap();
808        assert_eq!(block, "{\"key\":\n\"value\"}");
809    }
810
811    #[test]
812    fn extract_json_block_missing_closing_fence() {
813        let text = "prefix\n```json{\"key\":\"value\"}\n";
814        assert!(extract_json_block(text).is_none());
815    }
816
817    #[test]
818    fn extract_json_block_array_inline() {
819        let text = "prefix\n```json[1,2,3]```\nsuffix";
820        let block = extract_json_block(text).unwrap();
821        assert_eq!(block, "[1,2,3]");
822    }
823
824    #[test]
825    fn extract_json_block_multiple_fences() {
826        let text = "prefix\n```json{\"first\":1}``` middle\n```json{\"second\":2}``` suffix";
827        let block = extract_json_block(text).unwrap();
828        assert_eq!(block, "{\"first\":1}");
829    }
830
831    #[test]
832    fn extract_json_block_missing_returns_none() {
833        assert!(extract_json_block("no code block here").is_none());
834    }
835
836    #[test]
837    fn extract_string_array_from_json() {
838        let val: serde_json::Value = serde_json::json!(["a", "b", "c"]);
839        let result = extract_string_array(Some(&val));
840        assert_eq!(result, vec!["a", "b", "c"]);
841    }
842
843    #[test]
844    fn extract_string_array_none() {
845        let result = extract_string_array(None);
846        assert!(result.is_empty());
847    }
848
849    #[test]
850    fn parse_empty_object() {
851        let input = "{}";
852        let err = parse(input).unwrap_err();
853        let msg = err.to_string();
854        assert!(
855            msg.contains("agent response is missing required")
856                || msg.contains("JSON object lacks substantive"),
857            "unexpected error: {msg}"
858        );
859    }
860
861    #[test]
862    fn parse_with_learnings() {
863        let input = r#"{"status":"needs_review","summary":"Need to fix imports","accomplished":[],"remaining":["fix imports"],"files":["src/main.rs"],"learnings":["Use std::sync::Arc for shared state","Check imports before committing"]}"#;
864        let resp = parse(input).unwrap();
865        assert_eq!(resp.status, "needs_review");
866        assert_eq!(resp.summary, "Need to fix imports");
867        assert_eq!(resp.learnings.len(), 2);
868        assert_eq!(resp.learnings[0], "Use std::sync::Arc for shared state");
869        assert_eq!(resp.learnings[1], "Check imports before committing");
870    }
871
872    #[test]
873    fn parse_learnings_empty_by_default() {
874        let input = r#"{"status":"done","summary":"Completed","accomplished":["done"],"remaining":[],"files":[]}"#;
875        let resp = parse(input).unwrap();
876        assert!(resp.learnings.is_empty());
877    }
878
879    #[test]
880    fn parse_with_delegations() {
881        let input = r#"{
882            "status": "blocked",
883            "summary": "Task requires subtasks",
884            "accomplished": ["Analyzed requirements"],
885            "remaining": ["Waiting on subtasks"],
886            "files_changed": [],
887            "blockers": ["Waiting on delegated subtasks"],
888            "reason": "Decomposed into subtasks",
889            "delegations": [
890                {"title": "Implement login API", "body": "Create POST /api/login", "labels": ["backend"]},
891                {"title": "Add login form", "body": "Create React login component", "labels": ["frontend"]}
892            ]
893        }"#;
894        let resp = parse(input).unwrap();
895        assert_eq!(resp.status, "blocked");
896        assert_eq!(resp.delegations.len(), 2);
897        assert_eq!(resp.delegations[0].title, "Implement login API");
898        assert_eq!(resp.delegations[0].body, "Create POST /api/login");
899        assert_eq!(resp.delegations[0].labels, vec!["backend"]);
900        assert_eq!(resp.delegations[1].title, "Add login form");
901        assert_eq!(resp.delegations[1].labels, vec!["frontend"]);
902    }
903
904    #[test]
905    fn parse_delegations_empty_by_default() {
906        let input =
907            r#"{"status":"done","summary":"Done","accomplished":[],"remaining":[],"files":[]}"#;
908        let resp = parse(input).unwrap();
909        assert!(resp.delegations.is_empty());
910    }
911
912    #[test]
913    fn parse_delegations_without_labels() {
914        let input = r#"{
915            "status": "blocked",
916            "summary": "Delegating",
917            "delegations": [
918                {"title": "Subtask", "body": "Do the thing"}
919            ]
920        }"#;
921        let resp = parse(input).unwrap();
922        assert_eq!(resp.delegations.len(), 1);
923        assert_eq!(resp.delegations[0].title, "Subtask");
924        assert!(resp.delegations[0].labels.is_empty());
925    }
926
927    #[test]
928    fn parse_response_after_telemetry_json() {
929        // Telemetry JSON appears before the actual response JSON.
930        // The parser must skip the telemetry blob and find the real response.
931        let input = r#"Processing...
932{"type":"telemetry","message":"starting task","tokens":42}
933Some output here.
934{"status":"done","summary":"Fixed the bug","accomplished":["patched parser"],"remaining":[],"files":["src/parser.rs"]}
935"#;
936        let resp = parse(input).unwrap();
937        assert_eq!(resp.status, "done");
938        assert_eq!(resp.summary, "Fixed the bug");
939        assert_eq!(resp.files, vec!["src/parser.rs"]);
940    }
941
942    // --- Edge-case tests for line-boundary fence extraction ---
943
944    #[test]
945    fn extract_json_block_backticks_inside_json_string() {
946        // Triple-backticks appear inside a JSON string value.
947        // The old code would stop at the first ``` (inside the string).
948        // The new code must find the real closing fence at a line boundary.
949        let text = "```json\n{\"code\":\"example: ```snippet``` end\"}\n```\n";
950        let block = extract_json_block(text).unwrap();
951        assert_eq!(block, "{\"code\":\"example: ```snippet``` end\"}\n");
952    }
953
954    #[test]
955    fn extract_json_block_backticks_inside_json_string_parse_roundtrip() {
956        // Full parse roundtrip: fenced block with backticks inside a JSON string value.
957        let input = "```json\n{\"status\":\"done\",\"summary\":\"use ```code``` here\"}\n```\n";
958        let resp = parse(input).unwrap();
959        assert_eq!(resp.status, "done");
960        assert_eq!(resp.summary, "use ```code``` here");
961    }
962
963    #[test]
964    fn extract_json_block_inline_with_embedded_backtick_pairs() {
965        // Inline JSON: embedded backticks that are NOT triple should not confuse the parser.
966        let text = "```json{\"key\":\"a`b``c\"}```\n";
967        let block = extract_json_block(text).unwrap();
968        assert_eq!(block, "{\"key\":\"a`b``c\"}");
969    }
970
971    #[test]
972    fn parse_mixed_telemetry_and_fenced_response() {
973        // Telemetry JSON with backtick sequences followed by a fenced agent response.
974        let input = r#"Processing...
975{"type":"log","msg":"calling tool: ```bash```"}
976```json
977{"status":"done","summary":"fixed it","accomplished":["patched"],"remaining":[],"files":["src/x.rs"]}
978```
979"#;
980        let resp = parse(input).unwrap();
981        assert_eq!(resp.status, "done");
982        assert_eq!(resp.summary, "fixed it");
983        assert_eq!(resp.files, vec!["src/x.rs"]);
984    }
985
986    #[test]
987    fn parse_response_from_ndjson_multiple_objects() {
988        // NDJSON stream: only the last object has `status` — must select it.
989        let input = r#"{"type":"text","text":"Working on it..."}
990{"type":"tool_call","name":"bash","input":{"command":"cargo test"}}
991{"type":"tool_result","output":"test passed"}
992{"status":"needs_review","summary":"All tests pass","accomplished":["fixed bug"],"remaining":[],"files":["src/lib.rs"]}
993"#;
994        let resp = parse(input).unwrap();
995        assert_eq!(resp.status, "needs_review");
996        assert_eq!(resp.summary, "All tests pass");
997        assert_eq!(resp.files, vec!["src/lib.rs"]);
998    }
999
1000    // ── normalize_status tests ────────────────────────────────────────────────
1001
1002    #[test]
1003    fn parse_normalizes_ok_alias_to_done() {
1004        let input =
1005            r#"{"status":"ok","summary":"all good","accomplished":[],"remaining":[],"files":[]}"#;
1006        let resp = parse(input).unwrap();
1007        assert_eq!(resp.status, "done");
1008    }
1009
1010    #[test]
1011    fn parse_normalizes_success_alias_to_done() {
1012        let input = r#"{"status":"success","summary":"fixed it","accomplished":[],"remaining":[],"files":[]}"#;
1013        let resp = parse(input).unwrap();
1014        assert_eq!(resp.status, "done");
1015    }
1016
1017    #[test]
1018    fn parse_normalizes_completed_alias_to_done() {
1019        let input = r#"{"status":"completed","summary":"done","accomplished":[],"remaining":[],"files":[]}"#;
1020        let resp = parse(input).unwrap();
1021        assert_eq!(resp.status, "done");
1022    }
1023
1024    #[test]
1025    fn parse_normalizes_missed_alias_to_done_case_insensitive() {
1026        let input = r#"{"status":"MISSED","summary":"no setups found","accomplished":[],"remaining":[],"files":[]}"#;
1027        let resp = parse(input).unwrap();
1028        assert_eq!(resp.status, "done");
1029    }
1030
1031    #[test]
1032    fn parse_normalizes_changes_addressed_alias_to_done() {
1033        let input = r#"{"status":"changes_addressed","summary":"feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1034        let resp = parse(input).unwrap();
1035        assert_eq!(resp.status, "done");
1036    }
1037
1038    #[test]
1039    fn parse_normalizes_changes_requested_addressed_alias_to_done() {
1040        let input = r#"{"status":"changes_requested_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1041        let resp = parse(input).unwrap();
1042        assert_eq!(resp.status, "done");
1043    }
1044
1045    #[test]
1046    fn parse_normalizes_review_addressed_alias_to_done() {
1047        let input = r#"{"status":"review_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1048        let resp = parse(input).unwrap();
1049        assert_eq!(resp.status, "done");
1050    }
1051
1052    #[test]
1053    fn parse_normalizes_already_finalized_in_attempt_1_alias_to_done() {
1054        let input = r#"{"status":"already_finalized_in_attempt_1","summary":"work already done in attempt 1","accomplished":[],"remaining":[],"files":[]}"#;
1055        let resp = parse(input).unwrap();
1056        assert_eq!(resp.status, "done");
1057    }
1058
1059    #[test]
1060    fn parse_normalizes_noop_alias_to_done() {
1061        // noop / green / alerts_sent → done, matching #3273 alias map.
1062        let input = r#"{"status":"noop","summary":"nothing to do","accomplished":[],"remaining":[],"files":[]}"#;
1063        let resp = parse(input).unwrap();
1064        assert_eq!(resp.status, "done");
1065    }
1066
1067    #[test]
1068    fn parse_normalizes_green_alias_to_done() {
1069        let input = r#"{"status":"green","summary":"all passing","accomplished":[],"remaining":[],"files":[]}"#;
1070        let resp = parse(input).unwrap();
1071        assert_eq!(resp.status, "done");
1072    }
1073
1074    #[test]
1075    fn parse_normalizes_alerts_sent_alias_to_done() {
1076        let input = r#"{"status":"alerts_sent","summary":"notifications dispatched","accomplished":[],"remaining":[],"files":[]}"#;
1077        let resp = parse(input).unwrap();
1078        assert_eq!(resp.status, "done");
1079    }
1080
1081    #[test]
1082    fn parse_normalizes_verified_alias_to_done() {
1083        let input = r#"{"status":"verified","summary":"result verified correct","accomplished":[],"remaining":[],"files":[]}"#;
1084        let resp = parse(input).unwrap();
1085        assert_eq!(resp.status, "done");
1086    }
1087
1088    #[test]
1089    fn parse_normalizes_fixed_alias_to_done() {
1090        let input = r#"{"status":"fixed","summary":"bug has been fixed","accomplished":["fixed bug"],"remaining":[],"files":["src/parser.rs"]}"#;
1091        let resp = parse(input).unwrap();
1092        assert_eq!(resp.status, "done");
1093    }
1094
1095    #[test]
1096    fn parse_normalizes_alerts_fired_alias_to_done() {
1097        let input = r#"{"status":"alerts_fired","summary":"alerts dispatched","accomplished":[],"remaining":[],"files":[]}"#;
1098        let resp = parse(input).unwrap();
1099        assert_eq!(resp.status, "done");
1100    }
1101
1102    #[test]
1103    fn parse_normalizes_waiting_alias_to_in_progress() {
1104        // waiting → in_progress (distinct from running/partial in the alias map).
1105        let input = r#"{"status":"waiting","summary":"blocked on upstream","accomplished":[],"remaining":[],"files":[]}"#;
1106        let resp = parse(input).unwrap();
1107        assert_eq!(resp.status, "in_progress");
1108    }
1109
1110    #[test]
1111    fn parse_normalizes_changes_pushed_alias_to_done() {
1112        let input = r#"{"status":"changes_pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1113        let resp = parse(input).unwrap();
1114        assert_eq!(resp.status, "done");
1115    }
1116
1117    #[test]
1118    fn parse_normalizes_bare_pushed_alias_to_done() {
1119        let input = r#"{"status":"pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1120        let resp = parse(input).unwrap();
1121        assert_eq!(resp.status, "done");
1122    }
1123
1124    #[test]
1125    fn parse_normalizes_dynamic_pushed_to_branch_status_to_done() {
1126        let input = r#"{"status":"pushed to internal-155195-weekly-review-patterns-progress-and-next","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1127        let resp = parse(input).unwrap();
1128        assert_eq!(resp.status, "done");
1129    }
1130
1131    #[test]
1132    fn parse_normalizes_running_alias_to_in_progress() {
1133        let input = r#"{"status":"running","summary":"working","accomplished":[],"remaining":[],"files":[]}"#;
1134        let resp = parse(input).unwrap();
1135        assert_eq!(resp.status, "in_progress");
1136    }
1137
1138    #[test]
1139    fn parse_normalizes_reviewing_alias_to_in_review() {
1140        let input = r#"{"status":"reviewing","summary":"reviewing","accomplished":[],"remaining":[],"files":[]}"#;
1141        let resp = parse(input).unwrap();
1142        assert_eq!(resp.status, "in_review");
1143    }
1144
1145    #[test]
1146    fn parse_normalizes_pending_review_alias_to_needs_review() {
1147        let input = r#"{"status":"pending_review","summary":"ready","accomplished":[],"remaining":[],"files":[]}"#;
1148        let resp = parse(input).unwrap();
1149        assert_eq!(resp.status, "needs_review");
1150    }
1151
1152    #[test]
1153    fn parse_normalizes_error_alias_to_blocked() {
1154        let input =
1155            r#"{"status":"error","summary":"failed","accomplished":[],"remaining":[],"files":[]}"#;
1156        let resp = parse(input).unwrap();
1157        assert_eq!(resp.status, "blocked");
1158    }
1159
1160    #[test]
1161    fn parse_normalizes_failure_alias_to_blocked() {
1162        let input = r#"{"status":"failure","summary":"could not complete","accomplished":[],"remaining":[],"files":[]}"#;
1163        let resp = parse(input).unwrap();
1164        assert_eq!(resp.status, "blocked");
1165    }
1166
1167    #[test]
1168    fn parse_keeps_canonical_statuses_unchanged() {
1169        for status in &[
1170            "new",
1171            "routed",
1172            "in_progress",
1173            "done",
1174            "blocked",
1175            "in_review",
1176            "needs_review",
1177        ] {
1178            let input = format!(
1179                r#"{{"status":"{status}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
1180            );
1181            let resp = parse(&input).unwrap();
1182            assert_eq!(
1183                resp.status, *status,
1184                "canonical status '{status}' should be unchanged"
1185            );
1186        }
1187    }
1188
1189    #[test]
1190    fn parse_keeps_non_canonical_status_as_is_for_audit_trail() {
1191        // Non-canonical statuses that are not in the alias map are kept as-is
1192        // so operators can identify which statuses need to be added to normalize_status.
1193        let input = r#"{"status":"fix_deployed","summary":"deployed","accomplished":[],"remaining":[],"files":[]}"#;
1194        let resp = parse(input).unwrap();
1195        // Kept as-is (not normalized away) so build_run_audit can report it explicitly.
1196        assert_eq!(resp.status, "fix_deployed");
1197    }
1198
1199    #[test]
1200    fn parse_normalizes_heuristic_completion_alias_to_done() {
1201        // #3627: bare "addressed" and compound "duplicate_skipped" are not in the
1202        // literal alias map but clearly read as completion via the shared heuristic.
1203        for status in &["addressed", "duplicate_skipped"] {
1204            let input = format!(
1205                r#"{{"status":"{status}","summary":"done","accomplished":[],"remaining":[],"files":[]}}"#
1206            );
1207            let resp = parse(&input).unwrap();
1208            assert_eq!(
1209                resp.status, "done",
1210                "status '{status}' should normalize to done"
1211            );
1212        }
1213    }
1214
1215    #[test]
1216    fn parse_fails_when_summary_present_but_status_missing() {
1217        // Payload contains a supported field but omits status/result — should NOT
1218        // be treated as done. Regression test for gh-issue-2835.
1219        let input = r#"{"summary":"still working","accomplished":[],"remaining":[],"files":[]}"#;
1220        let result = parse(input);
1221        assert!(
1222            result.is_err(),
1223            "missing status should error, not default to done"
1224        );
1225    }
1226
1227    #[test]
1228    fn parse_fails_when_files_present_but_status_missing() {
1229        // Payload contains files/summary but no status — should not default to done.
1230        let input = r#"{"summary":"partial output","files":["src/foo.rs"]}"#;
1231        let result = parse(input);
1232        assert!(
1233            result.is_err(),
1234            "missing status should error, not default to done"
1235        );
1236    }
1237
1238    #[test]
1239    fn parse_fails_when_only_message_present_without_status() {
1240        // Payload contains only a message field, no status — should not default to done.
1241        let input = r#"{"message":"working on it"}"#;
1242        let result = parse(input);
1243        assert!(
1244            result.is_err(),
1245            "missing status should error, not default to done"
1246        );
1247    }
1248
1249    // ── Regression tests for gh-issue-2880 ───────────────────────────────
1250
1251    #[test]
1252    fn parse_regression_2880_ready_for_review_normalized() {
1253        // ready_for_review should be normalized to needs_review.
1254        let input = r#"{"status":"ready_for_review","summary":"Ready for review","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
1255        let resp = parse(input).unwrap();
1256        assert_eq!(resp.status, "needs_review");
1257    }
1258
1259    #[test]
1260    fn parse_regression_2880_status_with_output_accepted() {
1261        // status + output SHOULD be accepted (output is a substantive field).
1262        // This was already supported via summary extraction in map_generic_response.
1263        let input = r#"{"status":"done","output":"All tests passed"}"#;
1264        let resp = parse(input).unwrap();
1265        assert_eq!(resp.status, "done");
1266    }
1267
1268    #[test]
1269    fn parse_regression_2880_mixed_ndjson_chooses_best_candidate() {
1270        // In a mixed NDJSON stream:
1271        // - First blob: file path as status (like the bug)
1272        // - Later blob: proper response with done status
1273        // The parser should prefer the one with known status.
1274        let input = r#"{"status":"src/channels/discord_ws.rs","summary":"","accomplished":[],"remaining":[],"files":[]}
1275{"status":"done","summary":"Fixed websocket timeout","accomplished":["fixed"],"remaining":[],"files":["src/channels/discord_ws.rs"]}"#;
1276        let resp = parse(input).unwrap();
1277        // The parser now prefers known statuses, so it should pick the "done" one.
1278        assert_eq!(resp.status, "done");
1279        assert_eq!(resp.summary, "Fixed websocket timeout");
1280    }
1281
1282    #[test]
1283    fn parse_regression_2880_normalize_aliases() {
1284        // Verify all status aliases are normalized correctly.
1285        for (input, expected) in [
1286            ("ok", "done"),
1287            ("success", "done"),
1288            ("completed", "done"),
1289            ("complete", "done"),
1290            ("skipped", "done"),
1291            ("skip", "done"),
1292            ("no_changes_needed", "done"),
1293            ("no_trades_no_positions", "done"),
1294            ("changes_made", "done"),
1295            ("changes_pushed", "done"),
1296            ("changes_addressed", "done"),
1297            ("changes_requested_addressed", "done"),
1298            ("fixed", "done"),
1299            ("running", "in_progress"),
1300            ("partial", "in_progress"),
1301            ("reviewing", "in_review"),
1302            ("pending_review", "needs_review"),
1303            ("ready_for_review", "needs_review"),
1304            ("error", "blocked"),
1305            ("failed", "blocked"),
1306        ] {
1307            let input = format!(
1308                r#"{{"status":"{input}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
1309            );
1310            let resp = parse(&input).unwrap();
1311            assert_eq!(
1312                resp.status, expected,
1313                "status '{input}' should normalize to '{expected}'"
1314            );
1315        }
1316    }
1317
1318    #[test]
1319    fn parse_regression_2880_telemetry_skipped() {
1320        // Telemetry objects without proper response structure should be ignored.
1321        let input = r#"{"type":"telemetry","event":"tool_call"}
1322{"status":"done","summary":"Work complete","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
1323        let resp = parse(input).unwrap();
1324        assert_eq!(resp.status, "done");
1325        assert_eq!(resp.summary, "Work complete");
1326    }
1327
1328    // ── Regression tests for gh-issue-2979 ───────────────────────────────
1329
1330    #[test]
1331    fn parse_regression_2979_success_true_envelope() {
1332        // Claude returns prose + fenced JSON with `success: true` instead of `status`.
1333        // Reproduces internal:148284 attempt 3.
1334        let input = r#"The fix removes all actionable entry/SL/TP/R:R parameters from the bearish-mode trade section. The three candidates are now a plain watchlist table.
1335
1336```json
1337{"success": true, "summary": "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."}
1338```"#;
1339        let resp = parse(input).unwrap();
1340        assert_eq!(resp.status, "done");
1341        assert_eq!(
1342            resp.summary,
1343            "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."
1344        );
1345    }
1346
1347    #[test]
1348    fn parse_regression_2979_type_task_complete_envelope() {
1349        // Claude returns prose + fenced JSON with `type: "task_complete"` instead of `status`.
1350        // Reproduces internal:147845 attempt 7.
1351        let input = r#"The daily trending doc is complete at md/research/2026-04-22-daily-trending.md.
1352
1353```json
1354{
1355  "type": "task_complete",
1356  "summary": "Fetched global trending topics and saved to md/research/2026-04-22-daily-trending.md.",
1357  "files_changed": ["md/research/2026-04-22-daily-trending.md"]
1358}
1359```"#;
1360        let resp = parse(input).unwrap();
1361        assert_eq!(resp.status, "done");
1362        assert!(resp.summary.contains("Fetched global trending topics"));
1363        assert_eq!(resp.files, vec!["md/research/2026-04-22-daily-trending.md"]);
1364    }
1365
1366    #[test]
1367    fn parse_regression_2979_tests_passed_envelope() {
1368        // Claude returns prose + fenced JSON with `tests_passed: true` and `files_modified`.
1369        // Reproduces task 2955 attempt 9.
1370        let input = r#"PR created: gabrielkoerich/orch#2969
1371
1372The fix from attempt #8 was already committed. All quality gates pass.
1373
1374```json
1375{
1376  "summary": "Retry watermark persistence on store failure.",
1377  "files_modified": ["src/engine/review_poll.rs"],
1378  "pr_url": "https://github.com/gabrielkoerich/orch/pull/2969",
1379  "tests_passed": true,
1380  "quality_gates": ["cargo fmt", "cargo clippy", "cargo nextest run"]
1381}
1382```"#;
1383        let resp = parse(input).unwrap();
1384        assert_eq!(resp.status, "done");
1385        assert!(resp.summary.contains("Retry watermark persistence"));
1386        assert_eq!(resp.files, vec!["src/engine/review_poll.rs"]);
1387    }
1388
1389    #[test]
1390    fn parse_regression_2979_success_false_does_not_infer_done() {
1391        // success: false should NOT infer done — only success: true triggers inference.
1392        let input = r#"```json
1393{"success": false, "summary": "Could not complete the task."}
1394```"#;
1395        let result = parse(input);
1396        // success: false has no `status` and `success` is not a valid status value,
1397        // and infer_completion_status only fires for success=true, so this must error.
1398        assert!(
1399            result.is_err(),
1400            "success: false should not be treated as done"
1401        );
1402    }
1403
1404    #[test]
1405    fn parse_regression_2979_type_other_does_not_infer_done() {
1406        // type: "something_else" should NOT infer done.
1407        let input = r#"{"type": "tool_call", "summary": "calling bash"}"#;
1408        let result = parse(input);
1409        assert!(
1410            result.is_err(),
1411            "type other than task_complete should not be treated as done"
1412        );
1413    }
1414
1415    #[test]
1416    fn parse_regression_2979_files_modified_alias() {
1417        // files_modified should be picked up as the files list.
1418        let input = r#"{"status": "done", "summary": "done", "files_modified": ["src/foo.rs", "src/bar.rs"]}"#;
1419        let resp = parse(input).unwrap();
1420        assert_eq!(resp.files, vec!["src/foo.rs", "src/bar.rs"]);
1421    }
1422
1423    // ── Regression tests for gh-issue-3336 ───────────────────────────────
1424
1425    #[test]
1426    fn parse_regression_3336_jsonl_domain_status_field_rejected() {
1427        // Career pipeline JSONL output. Neither record has an orch canonical status
1428        // and neither has any substantive orch fields — must be rejected, not returned
1429        // with domain status "inbound-not-submitted" as the task status.
1430        let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
1431{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}"#;
1432        let err = parse(input).unwrap_err();
1433        let msg = err.to_string();
1434        assert!(
1435            msg.contains("agent output is not valid JSON or a supported JSON wrapper")
1436                || msg.contains("JSON object lacks substantive"),
1437            "unexpected error: {msg}"
1438        );
1439    }
1440
1441    #[test]
1442    fn parse_regression_3336_single_domain_record_rejected() {
1443        // A single domain JSON record with a non-canonical status and no orch fields.
1444        let input = r#"{"status":"inbound-not-submitted","date":"2026-06-15","role":"Engineer","company":"Acme"}"#;
1445        let err = parse(input).unwrap_err();
1446        let msg = err.to_string();
1447        assert!(
1448            msg.contains("agent output is not valid JSON or a supported JSON wrapper")
1449                || msg.contains("JSON object lacks substantive"),
1450            "unexpected error: {msg}"
1451        );
1452    }
1453
1454    #[test]
1455    fn parse_regression_3336_noncanonical_status_with_summary_still_accepted() {
1456        // A non-canonical orch status WITH a summary is a real agent response and must
1457        // still be accepted (not broken by the substantive-field guard).
1458        let input = r#"{"status":"fix_deployed","summary":"deployed to staging","accomplished":[],"remaining":[],"files":[]}"#;
1459        let resp = parse(input).unwrap();
1460        assert_eq!(resp.status, "fix_deployed");
1461        assert_eq!(resp.summary, "deployed to staging");
1462    }
1463
1464    #[test]
1465    fn parse_regression_3336_jsonl_domain_plus_orch_response_prefers_orch() {
1466        // JSONL output where domain records precede an actual orch response with a
1467        // canonical status. The parser must pick the orch response.
1468        let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
1469{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}
1470{"status":"done","summary":"Career radar written to md/career/2026-06-15-radar.md","accomplished":["Wrote career radar"],"remaining":[],"files":["md/career/2026-06-15-radar.md"]}"#;
1471        let resp = parse(input).unwrap();
1472        assert_eq!(resp.status, "done");
1473        assert_eq!(
1474            resp.summary,
1475            "Career radar written to md/career/2026-06-15-radar.md"
1476        );
1477        assert_eq!(resp.files, vec!["md/career/2026-06-15-radar.md"]);
1478    }
1479
1480    #[test]
1481    fn parse_regression_3336_noncanonical_with_output_accepted() {
1482        // A non-canonical status with `output` (handled by map_generic_response but not a
1483        // struct field on AgentResponse) must still be accepted — not rejected with a parse
1484        // error. Previously `has_substantive_fields` checked the deserialized struct and
1485        // missed `output`, causing this to fail with "JSON object lacks substantive".
1486        let input = r#"{"status":"mystery_status","output":"All tests passed"}"#;
1487        let resp = parse(input).unwrap();
1488        assert_eq!(resp.status, "mystery_status");
1489    }
1490
1491    #[test]
1492    fn parse_regression_3336_noncanonical_with_message_accepted() {
1493        // Same as above for `message` — also substantive but absent from the AgentResponse
1494        // struct, so direct deserialization leaves summary empty.
1495        let input = r#"{"status":"mystery_status","message":"All tests passed"}"#;
1496        let resp = parse(input).unwrap();
1497        assert_eq!(resp.status, "mystery_status");
1498    }
1499}