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