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];
531
532/// Infer a canonical completion status from known non-standard completion envelope shapes.
533///
534/// Claude agents sometimes return JSON without a `status`/`result` field, using
535/// alternative completion indicators instead. This function maps those shapes to
536/// a canonical status so they are not misclassified as `InvalidResponse`.
537///
538/// Recognized patterns (all map to `"done"`):
539/// - `{"success": true, ...}` — explicit success boolean
540/// - `{"type": "task_complete", ...}` — explicit task_complete type string
541/// - `{"tests_passed": true, ...}` — tests-passed completion signal
542fn infer_completion_status(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
543    // success: true
544    if obj.get("success").and_then(|v| v.as_bool()) == Some(true) {
545        return Some("done".to_string());
546    }
547    // type: "task_complete"
548    if obj.get("type").and_then(|v| v.as_str()) == Some("task_complete") {
549        return Some("done".to_string());
550    }
551    // tests_passed: true
552    if obj.get("tests_passed").and_then(|v| v.as_bool()) == Some(true) {
553        return Some("done".to_string());
554    }
555    None
556}
557
558/// Map a generic JSON object to AgentResponse.
559///
560/// Requires stricter shape checking: the object must have:
561/// 1. A status/result field (or a recognized completion envelope shape)
562/// 2. AND at least one substantive field OR an error field
563///
564/// This prevents acceptance of non-response JSON blobs (telemetry, tool outputs)
565/// that happen to contain a status field.
566fn map_generic_response(val: &serde_json::Value) -> anyhow::Result<AgentResponse> {
567    let obj = val.as_object().context("expected JSON object")?;
568
569    // Check for status or result field first; fall back to known completion envelopes.
570    let status = obj
571        .get("status")
572        .or_else(|| obj.get("result"))
573        .and_then(|v| v.as_str())
574        .map(String::from)
575        .or_else(|| infer_completion_status(obj))
576        .context("agent response is missing required 'status' or 'result' field")?;
577
578    // Require at least one substantive field OR an error field.
579    // This prevents accepting telemetry/event JSON blobs that happen to have
580    // a status field but are not actual agent responses.
581    let has_substantive_field = SUBSTANTIVE_RESPONSE_FIELDS
582        .iter()
583        .any(|key| obj.contains_key(*key));
584    let has_error = obj.contains_key("error");
585
586    if !has_substantive_field && !has_error {
587        anyhow::bail!("JSON object lacks substantive response fields");
588    }
589
590    let summary = obj
591        .get("summary")
592        .or_else(|| obj.get("message"))
593        .or_else(|| obj.get("output"))
594        .and_then(|v| v.as_str())
595        .unwrap_or("")
596        .to_string();
597
598    let accomplished = extract_string_array(obj.get("accomplished"));
599    let remaining = extract_string_array(obj.get("remaining"));
600    let files = {
601        let f = extract_string_array(obj.get("files"));
602        if !f.is_empty() {
603            f
604        } else {
605            let fc = extract_string_array(obj.get("files_changed"));
606            if !fc.is_empty() {
607                fc
608            } else {
609                extract_string_array(obj.get("files_modified"))
610            }
611        }
612    };
613    let error = obj.get("error").and_then(|v| v.as_str()).map(String::from);
614    let learnings = extract_string_array(obj.get("learnings"));
615
616    // Extract token counts if available
617    let input_tokens = extract_u64(obj.get("input_tokens"))
618        .or_else(|| extract_u64(obj.get("tokens_input")))
619        .or_else(|| extract_usage_tokens(obj.get("usage"), true));
620    let output_tokens = extract_u64(obj.get("output_tokens"))
621        .or_else(|| extract_u64(obj.get("tokens_output")))
622        .or_else(|| extract_usage_tokens(obj.get("usage"), false));
623
624    // Extract delegations. If the `delegations` field is present but malformed,
625    // return an error instead of silently ignoring it so callers can surface
626    // and debug malformed agent output.
627    let delegations = if let Some(v) = obj.get("delegations") {
628        serde_json::from_value::<Vec<Delegation>>(v.clone())
629            .with_context(|| format!("invalid delegations field: {}", v))?
630    } else {
631        Vec::new()
632    };
633
634    Ok(AgentResponse {
635        status,
636        summary,
637        accomplished,
638        remaining,
639        files,
640        error,
641        input_tokens,
642        output_tokens,
643        learnings,
644        delegations,
645    })
646}
647
648/// Extract a u64 from a JSON value.
649fn extract_u64(val: Option<&serde_json::Value>) -> Option<u64> {
650    val.and_then(|v| v.as_u64())
651}
652
653/// Extract tokens from a usage object (common in OpenAI-compatible APIs).
654fn extract_usage_tokens(usage: Option<&serde_json::Value>, is_input: bool) -> Option<u64> {
655    let obj = usage?.as_object()?;
656    let key = if is_input {
657        "input_tokens"
658    } else {
659        "output_tokens"
660    };
661    obj.get(key).and_then(|v| v.as_u64())
662}
663
664fn extract_string_array(val: Option<&serde_json::Value>) -> Vec<String> {
665    val.and_then(|v| v.as_array())
666        .map(|arr| {
667            arr.iter()
668                .filter_map(|v| v.as_str().map(String::from))
669                .collect()
670        })
671        .unwrap_or_default()
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn parse_direct_json() {
680        let input = r#"{"status":"done","summary":"Fixed bug","accomplished":["fixed it"],"remaining":[],"files":["src/main.rs"]}"#;
681        let resp = parse(input).unwrap();
682        assert_eq!(resp.status, "done");
683        assert_eq!(resp.summary, "Fixed bug");
684        assert_eq!(resp.accomplished, vec!["fixed it"]);
685        assert_eq!(resp.files, vec!["src/main.rs"]);
686        assert!(resp.error.is_none());
687    }
688
689    #[test]
690    fn parse_json_in_markdown_block() {
691        let input = r#"Here is the result:
692
693```json
694{"status":"in_progress","summary":"Working on it","accomplished":[],"remaining":["finish tests"],"files":[]}
695```
696
697Done.
698"#;
699        let resp = parse(input).unwrap();
700        assert_eq!(resp.status, "in_progress");
701        assert_eq!(resp.remaining, vec!["finish tests"]);
702    }
703
704    #[test]
705    fn parse_generic_json_with_different_field_names() {
706        let input = r#"{"result":"done","message":"All good","files":["a.rs","b.rs"]}"#;
707        let resp = parse(input).unwrap();
708        assert_eq!(resp.status, "done");
709        assert_eq!(resp.summary, "All good");
710        assert_eq!(resp.files, vec!["a.rs", "b.rs"]);
711    }
712
713    #[test]
714    fn parse_with_error_field() {
715        let input = r#"{"status":"blocked","summary":"Cannot proceed","accomplished":[],"remaining":[],"files":[],"error":"missing dependency"}"#;
716        let resp = parse(input).unwrap();
717        assert_eq!(resp.status, "blocked");
718        assert_eq!(resp.error, Some("missing dependency".to_string()));
719    }
720
721    #[test]
722    fn parse_fallback_raw_text() {
723        let input = "This is just plain text output from the agent.";
724        let err = parse(input).unwrap_err();
725        assert!(err
726            .to_string()
727            .contains("agent output is not valid JSON or a supported JSON wrapper"));
728    }
729
730    #[test]
731    fn parse_malformed_json_block_returns_error() {
732        let input = r#"Here is the result:
733
734```json
735{"status":"done","summary":"missing brace"
736```
737"#;
738        let resp = parse(input).unwrap();
739        assert_eq!(resp.status, "done");
740        assert_eq!(resp.summary, "missing brace");
741    }
742
743    #[test]
744    fn parse_json_with_trailing_comma() {
745        let input =
746            r#"{"status":"done","summary":"Fixed","accomplished":[],"remaining":[],"files":[],}"#;
747        let resp = parse(input).unwrap();
748        assert_eq!(resp.status, "done");
749        assert_eq!(resp.summary, "Fixed");
750    }
751
752    #[test]
753    fn parse_json_embedded_in_commentary() {
754        let input = r#"Done:
755{"status":"in_progress","summary":"working","accomplished":[],"remaining":["tests"],"files":[]}
756Thanks"#;
757        let resp = parse(input).unwrap();
758        assert_eq!(resp.status, "in_progress");
759        assert_eq!(resp.remaining, vec!["tests"]);
760    }
761
762    #[test]
763    fn extract_json_block_from_markdown() {
764        let text = "prefix\n```json\n{\"key\":\"value\"}\n```\nsuffix";
765        let block = extract_json_block(text).unwrap();
766        assert_eq!(block, "{\"key\":\"value\"}\n");
767    }
768
769    #[test]
770    fn extract_json_block_inline_fence() {
771        let text = "prefix\n```json{\"key\":\"value\"}```\nsuffix";
772        let block = extract_json_block(text).unwrap();
773        assert_eq!(block, "{\"key\":\"value\"}");
774    }
775
776    #[test]
777    fn extract_json_block_with_info_string() {
778        let text = "prefix\n```json some-meta\n{\"key\":\"value\"}\n```\nsuffix";
779        let block = extract_json_block(text).unwrap();
780        assert_eq!(block, "{\"key\":\"value\"}\n");
781    }
782
783    #[test]
784    fn extract_json_block_with_info_string_and_whitespace() {
785        let text = "prefix\n```json   some-meta   \n{\"key\":\"value\"}\n```\nsuffix";
786        let block = extract_json_block(text).unwrap();
787        assert_eq!(block, "{\"key\":\"value\"}\n");
788    }
789
790    #[test]
791    fn extract_json_block_inline_with_internal_newline() {
792        // Inline JSON that contains a newline inside the JSON value
793        let text = "prefix\n```json{\"key\":\"value\\nwith newline\"}```\nsuffix";
794        let block = extract_json_block(text).unwrap();
795        assert_eq!(block, r#"{"key":"value\nwith newline"}"#);
796    }
797
798    #[test]
799    fn extract_json_block_inline_with_whitespace_newline() {
800        // Inline JSON that contains a newline as whitespace between key and value
801        let text = "prefix\n```json{\"key\":\n\"value\"}```\nsuffix";
802        let block = extract_json_block(text).unwrap();
803        assert_eq!(block, "{\"key\":\n\"value\"}");
804    }
805
806    #[test]
807    fn extract_json_block_missing_closing_fence() {
808        let text = "prefix\n```json{\"key\":\"value\"}\n";
809        assert!(extract_json_block(text).is_none());
810    }
811
812    #[test]
813    fn extract_json_block_array_inline() {
814        let text = "prefix\n```json[1,2,3]```\nsuffix";
815        let block = extract_json_block(text).unwrap();
816        assert_eq!(block, "[1,2,3]");
817    }
818
819    #[test]
820    fn extract_json_block_multiple_fences() {
821        let text = "prefix\n```json{\"first\":1}``` middle\n```json{\"second\":2}``` suffix";
822        let block = extract_json_block(text).unwrap();
823        assert_eq!(block, "{\"first\":1}");
824    }
825
826    #[test]
827    fn extract_json_block_missing_returns_none() {
828        assert!(extract_json_block("no code block here").is_none());
829    }
830
831    #[test]
832    fn extract_string_array_from_json() {
833        let val: serde_json::Value = serde_json::json!(["a", "b", "c"]);
834        let result = extract_string_array(Some(&val));
835        assert_eq!(result, vec!["a", "b", "c"]);
836    }
837
838    #[test]
839    fn extract_string_array_none() {
840        let result = extract_string_array(None);
841        assert!(result.is_empty());
842    }
843
844    #[test]
845    fn parse_empty_object() {
846        let input = "{}";
847        let err = parse(input).unwrap_err();
848        let msg = err.to_string();
849        assert!(
850            msg.contains("agent response is missing required")
851                || msg.contains("JSON object lacks substantive"),
852            "unexpected error: {msg}"
853        );
854    }
855
856    #[test]
857    fn parse_with_learnings() {
858        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"]}"#;
859        let resp = parse(input).unwrap();
860        assert_eq!(resp.status, "needs_review");
861        assert_eq!(resp.summary, "Need to fix imports");
862        assert_eq!(resp.learnings.len(), 2);
863        assert_eq!(resp.learnings[0], "Use std::sync::Arc for shared state");
864        assert_eq!(resp.learnings[1], "Check imports before committing");
865    }
866
867    #[test]
868    fn parse_learnings_empty_by_default() {
869        let input = r#"{"status":"done","summary":"Completed","accomplished":["done"],"remaining":[],"files":[]}"#;
870        let resp = parse(input).unwrap();
871        assert!(resp.learnings.is_empty());
872    }
873
874    #[test]
875    fn parse_with_delegations() {
876        let input = r#"{
877            "status": "blocked",
878            "summary": "Task requires subtasks",
879            "accomplished": ["Analyzed requirements"],
880            "remaining": ["Waiting on subtasks"],
881            "files_changed": [],
882            "blockers": ["Waiting on delegated subtasks"],
883            "reason": "Decomposed into subtasks",
884            "delegations": [
885                {"title": "Implement login API", "body": "Create POST /api/login", "labels": ["backend"]},
886                {"title": "Add login form", "body": "Create React login component", "labels": ["frontend"]}
887            ]
888        }"#;
889        let resp = parse(input).unwrap();
890        assert_eq!(resp.status, "blocked");
891        assert_eq!(resp.delegations.len(), 2);
892        assert_eq!(resp.delegations[0].title, "Implement login API");
893        assert_eq!(resp.delegations[0].body, "Create POST /api/login");
894        assert_eq!(resp.delegations[0].labels, vec!["backend"]);
895        assert_eq!(resp.delegations[1].title, "Add login form");
896        assert_eq!(resp.delegations[1].labels, vec!["frontend"]);
897    }
898
899    #[test]
900    fn parse_delegations_empty_by_default() {
901        let input =
902            r#"{"status":"done","summary":"Done","accomplished":[],"remaining":[],"files":[]}"#;
903        let resp = parse(input).unwrap();
904        assert!(resp.delegations.is_empty());
905    }
906
907    #[test]
908    fn parse_delegations_without_labels() {
909        let input = r#"{
910            "status": "blocked",
911            "summary": "Delegating",
912            "delegations": [
913                {"title": "Subtask", "body": "Do the thing"}
914            ]
915        }"#;
916        let resp = parse(input).unwrap();
917        assert_eq!(resp.delegations.len(), 1);
918        assert_eq!(resp.delegations[0].title, "Subtask");
919        assert!(resp.delegations[0].labels.is_empty());
920    }
921
922    #[test]
923    fn parse_response_after_telemetry_json() {
924        // Telemetry JSON appears before the actual response JSON.
925        // The parser must skip the telemetry blob and find the real response.
926        let input = r#"Processing...
927{"type":"telemetry","message":"starting task","tokens":42}
928Some output here.
929{"status":"done","summary":"Fixed the bug","accomplished":["patched parser"],"remaining":[],"files":["src/parser.rs"]}
930"#;
931        let resp = parse(input).unwrap();
932        assert_eq!(resp.status, "done");
933        assert_eq!(resp.summary, "Fixed the bug");
934        assert_eq!(resp.files, vec!["src/parser.rs"]);
935    }
936
937    // --- Edge-case tests for line-boundary fence extraction ---
938
939    #[test]
940    fn extract_json_block_backticks_inside_json_string() {
941        // Triple-backticks appear inside a JSON string value.
942        // The old code would stop at the first ``` (inside the string).
943        // The new code must find the real closing fence at a line boundary.
944        let text = "```json\n{\"code\":\"example: ```snippet``` end\"}\n```\n";
945        let block = extract_json_block(text).unwrap();
946        assert_eq!(block, "{\"code\":\"example: ```snippet``` end\"}\n");
947    }
948
949    #[test]
950    fn extract_json_block_backticks_inside_json_string_parse_roundtrip() {
951        // Full parse roundtrip: fenced block with backticks inside a JSON string value.
952        let input = "```json\n{\"status\":\"done\",\"summary\":\"use ```code``` here\"}\n```\n";
953        let resp = parse(input).unwrap();
954        assert_eq!(resp.status, "done");
955        assert_eq!(resp.summary, "use ```code``` here");
956    }
957
958    #[test]
959    fn extract_json_block_inline_with_embedded_backtick_pairs() {
960        // Inline JSON: embedded backticks that are NOT triple should not confuse the parser.
961        let text = "```json{\"key\":\"a`b``c\"}```\n";
962        let block = extract_json_block(text).unwrap();
963        assert_eq!(block, "{\"key\":\"a`b``c\"}");
964    }
965
966    #[test]
967    fn parse_mixed_telemetry_and_fenced_response() {
968        // Telemetry JSON with backtick sequences followed by a fenced agent response.
969        let input = r#"Processing...
970{"type":"log","msg":"calling tool: ```bash```"}
971```json
972{"status":"done","summary":"fixed it","accomplished":["patched"],"remaining":[],"files":["src/x.rs"]}
973```
974"#;
975        let resp = parse(input).unwrap();
976        assert_eq!(resp.status, "done");
977        assert_eq!(resp.summary, "fixed it");
978        assert_eq!(resp.files, vec!["src/x.rs"]);
979    }
980
981    #[test]
982    fn parse_response_from_ndjson_multiple_objects() {
983        // NDJSON stream: only the last object has `status` — must select it.
984        let input = r#"{"type":"text","text":"Working on it..."}
985{"type":"tool_call","name":"bash","input":{"command":"cargo test"}}
986{"type":"tool_result","output":"test passed"}
987{"status":"needs_review","summary":"All tests pass","accomplished":["fixed bug"],"remaining":[],"files":["src/lib.rs"]}
988"#;
989        let resp = parse(input).unwrap();
990        assert_eq!(resp.status, "needs_review");
991        assert_eq!(resp.summary, "All tests pass");
992        assert_eq!(resp.files, vec!["src/lib.rs"]);
993    }
994
995    // ── normalize_status tests ────────────────────────────────────────────────
996
997    #[test]
998    fn parse_normalizes_ok_alias_to_done() {
999        let input =
1000            r#"{"status":"ok","summary":"all good","accomplished":[],"remaining":[],"files":[]}"#;
1001        let resp = parse(input).unwrap();
1002        assert_eq!(resp.status, "done");
1003    }
1004
1005    #[test]
1006    fn parse_normalizes_success_alias_to_done() {
1007        let input = r#"{"status":"success","summary":"fixed it","accomplished":[],"remaining":[],"files":[]}"#;
1008        let resp = parse(input).unwrap();
1009        assert_eq!(resp.status, "done");
1010    }
1011
1012    #[test]
1013    fn parse_normalizes_completed_alias_to_done() {
1014        let input = r#"{"status":"completed","summary":"done","accomplished":[],"remaining":[],"files":[]}"#;
1015        let resp = parse(input).unwrap();
1016        assert_eq!(resp.status, "done");
1017    }
1018
1019    #[test]
1020    fn parse_normalizes_missed_alias_to_done_case_insensitive() {
1021        let input = r#"{"status":"MISSED","summary":"no setups found","accomplished":[],"remaining":[],"files":[]}"#;
1022        let resp = parse(input).unwrap();
1023        assert_eq!(resp.status, "done");
1024    }
1025
1026    #[test]
1027    fn parse_normalizes_changes_addressed_alias_to_done() {
1028        let input = r#"{"status":"changes_addressed","summary":"feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1029        let resp = parse(input).unwrap();
1030        assert_eq!(resp.status, "done");
1031    }
1032
1033    #[test]
1034    fn parse_normalizes_changes_requested_addressed_alias_to_done() {
1035        let input = r#"{"status":"changes_requested_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1036        let resp = parse(input).unwrap();
1037        assert_eq!(resp.status, "done");
1038    }
1039
1040    #[test]
1041    fn parse_normalizes_review_addressed_alias_to_done() {
1042        let input = r#"{"status":"review_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1043        let resp = parse(input).unwrap();
1044        assert_eq!(resp.status, "done");
1045    }
1046
1047    #[test]
1048    fn parse_normalizes_already_finalized_in_attempt_1_alias_to_done() {
1049        let input = r#"{"status":"already_finalized_in_attempt_1","summary":"work already done in attempt 1","accomplished":[],"remaining":[],"files":[]}"#;
1050        let resp = parse(input).unwrap();
1051        assert_eq!(resp.status, "done");
1052    }
1053
1054    #[test]
1055    fn parse_normalizes_noop_alias_to_done() {
1056        // noop / green / alerts_sent → done, matching #3273 alias map.
1057        let input = r#"{"status":"noop","summary":"nothing to do","accomplished":[],"remaining":[],"files":[]}"#;
1058        let resp = parse(input).unwrap();
1059        assert_eq!(resp.status, "done");
1060    }
1061
1062    #[test]
1063    fn parse_normalizes_green_alias_to_done() {
1064        let input = r#"{"status":"green","summary":"all passing","accomplished":[],"remaining":[],"files":[]}"#;
1065        let resp = parse(input).unwrap();
1066        assert_eq!(resp.status, "done");
1067    }
1068
1069    #[test]
1070    fn parse_normalizes_alerts_sent_alias_to_done() {
1071        let input = r#"{"status":"alerts_sent","summary":"notifications dispatched","accomplished":[],"remaining":[],"files":[]}"#;
1072        let resp = parse(input).unwrap();
1073        assert_eq!(resp.status, "done");
1074    }
1075
1076    #[test]
1077    fn parse_normalizes_verified_alias_to_done() {
1078        let input = r#"{"status":"verified","summary":"result verified correct","accomplished":[],"remaining":[],"files":[]}"#;
1079        let resp = parse(input).unwrap();
1080        assert_eq!(resp.status, "done");
1081    }
1082
1083    #[test]
1084    fn parse_normalizes_fixed_alias_to_done() {
1085        let input = r#"{"status":"fixed","summary":"bug has been fixed","accomplished":["fixed bug"],"remaining":[],"files":["src/parser.rs"]}"#;
1086        let resp = parse(input).unwrap();
1087        assert_eq!(resp.status, "done");
1088    }
1089
1090    #[test]
1091    fn parse_normalizes_alerts_fired_alias_to_done() {
1092        let input = r#"{"status":"alerts_fired","summary":"alerts dispatched","accomplished":[],"remaining":[],"files":[]}"#;
1093        let resp = parse(input).unwrap();
1094        assert_eq!(resp.status, "done");
1095    }
1096
1097    #[test]
1098    fn parse_normalizes_waiting_alias_to_in_progress() {
1099        // waiting → in_progress (distinct from running/partial in the alias map).
1100        let input = r#"{"status":"waiting","summary":"blocked on upstream","accomplished":[],"remaining":[],"files":[]}"#;
1101        let resp = parse(input).unwrap();
1102        assert_eq!(resp.status, "in_progress");
1103    }
1104
1105    #[test]
1106    fn parse_normalizes_changes_pushed_alias_to_done() {
1107        let input = r#"{"status":"changes_pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1108        let resp = parse(input).unwrap();
1109        assert_eq!(resp.status, "done");
1110    }
1111
1112    #[test]
1113    fn parse_normalizes_bare_pushed_alias_to_done() {
1114        let input = r#"{"status":"pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1115        let resp = parse(input).unwrap();
1116        assert_eq!(resp.status, "done");
1117    }
1118
1119    #[test]
1120    fn parse_normalizes_dynamic_pushed_to_branch_status_to_done() {
1121        let input = r#"{"status":"pushed to internal-155195-weekly-review-patterns-progress-and-next","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1122        let resp = parse(input).unwrap();
1123        assert_eq!(resp.status, "done");
1124    }
1125
1126    #[test]
1127    fn parse_normalizes_running_alias_to_in_progress() {
1128        let input = r#"{"status":"running","summary":"working","accomplished":[],"remaining":[],"files":[]}"#;
1129        let resp = parse(input).unwrap();
1130        assert_eq!(resp.status, "in_progress");
1131    }
1132
1133    #[test]
1134    fn parse_normalizes_reviewing_alias_to_in_review() {
1135        let input = r#"{"status":"reviewing","summary":"reviewing","accomplished":[],"remaining":[],"files":[]}"#;
1136        let resp = parse(input).unwrap();
1137        assert_eq!(resp.status, "in_review");
1138    }
1139
1140    #[test]
1141    fn parse_normalizes_pending_review_alias_to_needs_review() {
1142        let input = r#"{"status":"pending_review","summary":"ready","accomplished":[],"remaining":[],"files":[]}"#;
1143        let resp = parse(input).unwrap();
1144        assert_eq!(resp.status, "needs_review");
1145    }
1146
1147    #[test]
1148    fn parse_normalizes_error_alias_to_blocked() {
1149        let input =
1150            r#"{"status":"error","summary":"failed","accomplished":[],"remaining":[],"files":[]}"#;
1151        let resp = parse(input).unwrap();
1152        assert_eq!(resp.status, "blocked");
1153    }
1154
1155    #[test]
1156    fn parse_normalizes_failure_alias_to_blocked() {
1157        let input = r#"{"status":"failure","summary":"could not complete","accomplished":[],"remaining":[],"files":[]}"#;
1158        let resp = parse(input).unwrap();
1159        assert_eq!(resp.status, "blocked");
1160    }
1161
1162    #[test]
1163    fn parse_keeps_canonical_statuses_unchanged() {
1164        for status in &[
1165            "new",
1166            "routed",
1167            "in_progress",
1168            "done",
1169            "blocked",
1170            "in_review",
1171            "needs_review",
1172        ] {
1173            let input = format!(
1174                r#"{{"status":"{status}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
1175            );
1176            let resp = parse(&input).unwrap();
1177            assert_eq!(
1178                resp.status, *status,
1179                "canonical status '{status}' should be unchanged"
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn parse_keeps_non_canonical_status_as_is_for_audit_trail() {
1186        // Non-canonical statuses that are not in the alias map are kept as-is
1187        // so operators can identify which statuses need to be added to normalize_status.
1188        let input = r#"{"status":"fix_deployed","summary":"deployed","accomplished":[],"remaining":[],"files":[]}"#;
1189        let resp = parse(input).unwrap();
1190        // Kept as-is (not normalized away) so build_run_audit can report it explicitly.
1191        assert_eq!(resp.status, "fix_deployed");
1192    }
1193
1194    #[test]
1195    fn parse_normalizes_heuristic_completion_alias_to_done() {
1196        // #3627: bare "addressed" and compound "duplicate_skipped" are not in the
1197        // literal alias map but clearly read as completion via the shared heuristic.
1198        for status in &["addressed", "duplicate_skipped"] {
1199            let input = format!(
1200                r#"{{"status":"{status}","summary":"done","accomplished":[],"remaining":[],"files":[]}}"#
1201            );
1202            let resp = parse(&input).unwrap();
1203            assert_eq!(
1204                resp.status, "done",
1205                "status '{status}' should normalize to done"
1206            );
1207        }
1208    }
1209
1210    #[test]
1211    fn parse_fails_when_summary_present_but_status_missing() {
1212        // Payload contains a supported field but omits status/result — should NOT
1213        // be treated as done. Regression test for gh-issue-2835.
1214        let input = r#"{"summary":"still working","accomplished":[],"remaining":[],"files":[]}"#;
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_files_present_but_status_missing() {
1224        // Payload contains files/summary but no status — should not default to done.
1225        let input = r#"{"summary":"partial output","files":["src/foo.rs"]}"#;
1226        let result = parse(input);
1227        assert!(
1228            result.is_err(),
1229            "missing status should error, not default to done"
1230        );
1231    }
1232
1233    #[test]
1234    fn parse_fails_when_only_message_present_without_status() {
1235        // Payload contains only a message field, no status — should not default to done.
1236        let input = r#"{"message":"working on it"}"#;
1237        let result = parse(input);
1238        assert!(
1239            result.is_err(),
1240            "missing status should error, not default to done"
1241        );
1242    }
1243
1244    // ── Regression tests for gh-issue-2880 ───────────────────────────────
1245
1246    #[test]
1247    fn parse_regression_2880_ready_for_review_normalized() {
1248        // ready_for_review should be normalized to needs_review.
1249        let input = r#"{"status":"ready_for_review","summary":"Ready for review","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
1250        let resp = parse(input).unwrap();
1251        assert_eq!(resp.status, "needs_review");
1252    }
1253
1254    #[test]
1255    fn parse_regression_2880_status_with_output_accepted() {
1256        // status + output SHOULD be accepted (output is a substantive field).
1257        // This was already supported via summary extraction in map_generic_response.
1258        let input = r#"{"status":"done","output":"All tests passed"}"#;
1259        let resp = parse(input).unwrap();
1260        assert_eq!(resp.status, "done");
1261    }
1262
1263    #[test]
1264    fn parse_regression_2880_mixed_ndjson_chooses_best_candidate() {
1265        // In a mixed NDJSON stream:
1266        // - First blob: file path as status (like the bug)
1267        // - Later blob: proper response with done status
1268        // The parser should prefer the one with known status.
1269        let input = r#"{"status":"src/channels/discord_ws.rs","summary":"","accomplished":[],"remaining":[],"files":[]}
1270{"status":"done","summary":"Fixed websocket timeout","accomplished":["fixed"],"remaining":[],"files":["src/channels/discord_ws.rs"]}"#;
1271        let resp = parse(input).unwrap();
1272        // The parser now prefers known statuses, so it should pick the "done" one.
1273        assert_eq!(resp.status, "done");
1274        assert_eq!(resp.summary, "Fixed websocket timeout");
1275    }
1276
1277    #[test]
1278    fn parse_regression_2880_normalize_aliases() {
1279        // Verify all status aliases are normalized correctly.
1280        for (input, expected) in [
1281            ("ok", "done"),
1282            ("success", "done"),
1283            ("completed", "done"),
1284            ("complete", "done"),
1285            ("skipped", "done"),
1286            ("skip", "done"),
1287            ("no_changes_needed", "done"),
1288            ("no_trades_no_positions", "done"),
1289            ("changes_made", "done"),
1290            ("changes_pushed", "done"),
1291            ("changes_addressed", "done"),
1292            ("changes_requested_addressed", "done"),
1293            ("fixed", "done"),
1294            ("running", "in_progress"),
1295            ("partial", "in_progress"),
1296            ("reviewing", "in_review"),
1297            ("pending_review", "needs_review"),
1298            ("ready_for_review", "needs_review"),
1299            ("error", "blocked"),
1300            ("failed", "blocked"),
1301        ] {
1302            let input = format!(
1303                r#"{{"status":"{input}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
1304            );
1305            let resp = parse(&input).unwrap();
1306            assert_eq!(
1307                resp.status, expected,
1308                "status '{input}' should normalize to '{expected}'"
1309            );
1310        }
1311    }
1312
1313    #[test]
1314    fn parse_regression_2880_telemetry_skipped() {
1315        // Telemetry objects without proper response structure should be ignored.
1316        let input = r#"{"type":"telemetry","event":"tool_call"}
1317{"status":"done","summary":"Work complete","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
1318        let resp = parse(input).unwrap();
1319        assert_eq!(resp.status, "done");
1320        assert_eq!(resp.summary, "Work complete");
1321    }
1322
1323    // ── Regression tests for gh-issue-2979 ───────────────────────────────
1324
1325    #[test]
1326    fn parse_regression_2979_success_true_envelope() {
1327        // Claude returns prose + fenced JSON with `success: true` instead of `status`.
1328        // Reproduces internal:148284 attempt 3.
1329        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.
1330
1331```json
1332{"success": true, "summary": "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."}
1333```"#;
1334        let resp = parse(input).unwrap();
1335        assert_eq!(resp.status, "done");
1336        assert_eq!(
1337            resp.summary,
1338            "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."
1339        );
1340    }
1341
1342    #[test]
1343    fn parse_regression_2979_type_task_complete_envelope() {
1344        // Claude returns prose + fenced JSON with `type: "task_complete"` instead of `status`.
1345        // Reproduces internal:147845 attempt 7.
1346        let input = r#"The daily trending doc is complete at md/research/2026-04-22-daily-trending.md.
1347
1348```json
1349{
1350  "type": "task_complete",
1351  "summary": "Fetched global trending topics and saved to md/research/2026-04-22-daily-trending.md.",
1352  "files_changed": ["md/research/2026-04-22-daily-trending.md"]
1353}
1354```"#;
1355        let resp = parse(input).unwrap();
1356        assert_eq!(resp.status, "done");
1357        assert!(resp.summary.contains("Fetched global trending topics"));
1358        assert_eq!(resp.files, vec!["md/research/2026-04-22-daily-trending.md"]);
1359    }
1360
1361    #[test]
1362    fn parse_regression_2979_tests_passed_envelope() {
1363        // Claude returns prose + fenced JSON with `tests_passed: true` and `files_modified`.
1364        // Reproduces task 2955 attempt 9.
1365        let input = r#"PR created: gabrielkoerich/orch#2969
1366
1367The fix from attempt #8 was already committed. All quality gates pass.
1368
1369```json
1370{
1371  "summary": "Retry watermark persistence on store failure.",
1372  "files_modified": ["src/engine/review_poll.rs"],
1373  "pr_url": "https://github.com/gabrielkoerich/orch/pull/2969",
1374  "tests_passed": true,
1375  "quality_gates": ["cargo fmt", "cargo clippy", "cargo nextest run"]
1376}
1377```"#;
1378        let resp = parse(input).unwrap();
1379        assert_eq!(resp.status, "done");
1380        assert!(resp.summary.contains("Retry watermark persistence"));
1381        assert_eq!(resp.files, vec!["src/engine/review_poll.rs"]);
1382    }
1383
1384    #[test]
1385    fn parse_regression_2979_success_false_does_not_infer_done() {
1386        // success: false should NOT infer done — only success: true triggers inference.
1387        let input = r#"```json
1388{"success": false, "summary": "Could not complete the task."}
1389```"#;
1390        let result = parse(input);
1391        // success: false has no `status` and `success` is not a valid status value,
1392        // and infer_completion_status only fires for success=true, so this must error.
1393        assert!(
1394            result.is_err(),
1395            "success: false should not be treated as done"
1396        );
1397    }
1398
1399    #[test]
1400    fn parse_regression_2979_type_other_does_not_infer_done() {
1401        // type: "something_else" should NOT infer done.
1402        let input = r#"{"type": "tool_call", "summary": "calling bash"}"#;
1403        let result = parse(input);
1404        assert!(
1405            result.is_err(),
1406            "type other than task_complete should not be treated as done"
1407        );
1408    }
1409
1410    #[test]
1411    fn parse_regression_2979_files_modified_alias() {
1412        // files_modified should be picked up as the files list.
1413        let input = r#"{"status": "done", "summary": "done", "files_modified": ["src/foo.rs", "src/bar.rs"]}"#;
1414        let resp = parse(input).unwrap();
1415        assert_eq!(resp.files, vec!["src/foo.rs", "src/bar.rs"]);
1416    }
1417
1418    // ── Regression tests for gh-issue-3336 ───────────────────────────────
1419
1420    #[test]
1421    fn parse_regression_3336_jsonl_domain_status_field_rejected() {
1422        // Career pipeline JSONL output. Neither record has an orch canonical status
1423        // and neither has any substantive orch fields — must be rejected, not returned
1424        // with domain status "inbound-not-submitted" as the task status.
1425        let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
1426{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}"#;
1427        let err = parse(input).unwrap_err();
1428        let msg = err.to_string();
1429        assert!(
1430            msg.contains("agent output is not valid JSON or a supported JSON wrapper")
1431                || msg.contains("JSON object lacks substantive"),
1432            "unexpected error: {msg}"
1433        );
1434    }
1435
1436    #[test]
1437    fn parse_regression_3336_single_domain_record_rejected() {
1438        // A single domain JSON record with a non-canonical status and no orch fields.
1439        let input = r#"{"status":"inbound-not-submitted","date":"2026-06-15","role":"Engineer","company":"Acme"}"#;
1440        let err = parse(input).unwrap_err();
1441        let msg = err.to_string();
1442        assert!(
1443            msg.contains("agent output is not valid JSON or a supported JSON wrapper")
1444                || msg.contains("JSON object lacks substantive"),
1445            "unexpected error: {msg}"
1446        );
1447    }
1448
1449    #[test]
1450    fn parse_regression_3336_noncanonical_status_with_summary_still_accepted() {
1451        // A non-canonical orch status WITH a summary is a real agent response and must
1452        // still be accepted (not broken by the substantive-field guard).
1453        let input = r#"{"status":"fix_deployed","summary":"deployed to staging","accomplished":[],"remaining":[],"files":[]}"#;
1454        let resp = parse(input).unwrap();
1455        assert_eq!(resp.status, "fix_deployed");
1456        assert_eq!(resp.summary, "deployed to staging");
1457    }
1458
1459    #[test]
1460    fn parse_regression_3336_jsonl_domain_plus_orch_response_prefers_orch() {
1461        // JSONL output where domain records precede an actual orch response with a
1462        // canonical status. The parser must pick the orch response.
1463        let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
1464{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}
1465{"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"]}"#;
1466        let resp = parse(input).unwrap();
1467        assert_eq!(resp.status, "done");
1468        assert_eq!(
1469            resp.summary,
1470            "Career radar written to md/career/2026-06-15-radar.md"
1471        );
1472        assert_eq!(resp.files, vec!["md/career/2026-06-15-radar.md"]);
1473    }
1474
1475    #[test]
1476    fn parse_regression_3336_noncanonical_with_output_accepted() {
1477        // A non-canonical status with `output` (handled by map_generic_response but not a
1478        // struct field on AgentResponse) must still be accepted — not rejected with a parse
1479        // error. Previously `has_substantive_fields` checked the deserialized struct and
1480        // missed `output`, causing this to fail with "JSON object lacks substantive".
1481        let input = r#"{"status":"mystery_status","output":"All tests passed"}"#;
1482        let resp = parse(input).unwrap();
1483        assert_eq!(resp.status, "mystery_status");
1484    }
1485
1486    #[test]
1487    fn parse_regression_3336_noncanonical_with_message_accepted() {
1488        // Same as above for `message` — also substantive but absent from the AgentResponse
1489        // struct, so direct deserialization leaves summary empty.
1490        let input = r#"{"status":"mystery_status","message":"All tests passed"}"#;
1491        let resp = parse(input).unwrap();
1492        assert_eq!(resp.status, "mystery_status");
1493    }
1494}