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