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