orch-cli 0.91.1

Orch, the agent orchestration engine: runs coding agents on tasks and cron jobs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
//! Agent response parser — normalizes JSON output from different agents.
//!
//! Each agent (Claude, Codex, OpenCode) returns a different JSON shape.
//! This module parses them all into a common `AgentResponse` struct.
//! Replaces `scripts/parse_response.sh` + `jq` pipelines.

use anyhow::Context;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::io::Read;

static TRAILING_COMMA_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r",(\s*[}\]])").expect("trailing comma regex is valid"));

/// Normalized agent response.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentResponse {
    pub status: String,
    #[serde(default)]
    pub summary: String,
    #[serde(default)]
    pub accomplished: Vec<String>,
    #[serde(default)]
    pub remaining: Vec<String>,
    #[serde(default, alias = "files_changed", alias = "files_modified")]
    pub files: Vec<String>,
    #[serde(default)]
    pub error: Option<String>,
    /// Input token count (if available from agent).
    #[serde(default)]
    pub input_tokens: Option<u64>,
    /// Output token count (if available from agent).
    #[serde(default)]
    pub output_tokens: Option<u64>,
    /// Key learnings from this attempt (for memory persistence across retries).
    #[serde(default)]
    pub learnings: Vec<String>,
    /// Subtask delegations — agent requests child tasks to be created.
    #[serde(default)]
    pub delegations: Vec<Delegation>,
}

/// A subtask delegation requested by an agent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delegation {
    pub title: String,
    pub body: String,
    #[serde(default)]
    pub labels: Vec<String>,
}

/// Parse an agent response from a file path (or stdin if "-").
pub fn parse_and_print(path: &str) -> anyhow::Result<()> {
    let content = if path == "-" {
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .context("reading stdin")?;
        buf
    } else {
        std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
    };

    let response = parse(&content)?;
    println!("{}", serde_json::to_string_pretty(&response)?);
    Ok(())
}

/// Known canonical statuses - used to prefer better candidates.
/// NOTE: Does NOT include aliases like "ready_for_review" - those are
/// normalized to canonical forms in normalize_status().
fn status_is_known(status: &str) -> bool {
    matches!(
        status,
        "new"
            | "routed"
            | "in_progress"
            | "done"
            | "completed"
            | "ok"
            | "success"
            | "running"
            | "blocked"
            | "error"
            | "failed"
            | "in_review"
            | "reviewing"
            | "needs_review"
            | "pending_review"
    )
}

/// Parse raw agent output into a normalized response.
pub fn parse(raw: &str) -> anyhow::Result<AgentResponse> {
    let mut last_err: Option<anyhow::Error> = None;
    let mut saw_jsonish_candidate = false;
    // Pair: (parsed response, raw candidate string) so we can check the raw object
    // for substantive fields that map_generic_response handles but that direct
    // deserialization into AgentResponse ignores (e.g. "output", "message").
    let mut best_candidate: Option<(AgentResponse, String)> = None;

    for candidate in json_candidates(raw) {
        match parse_candidate(&candidate) {
            Ok(mut resp) => {
                resp = normalize_status(resp);
                // If status is known, return immediately (good candidate found).
                if status_is_known(&resp.status) {
                    return Ok(resp);
                }
                // Non-canonical status - remember it but keep looking for better.
                if best_candidate.is_none() {
                    best_candidate = Some((resp, candidate));
                }
            }
            Err(err) => {
                if err.to_string() != "invalid agent response candidate" {
                    saw_jsonish_candidate = true;
                    last_err = Some(err);
                }
            }
        }
    }

    // If we found a candidate with non-canonical status but no known-status
    // candidate was found, use the best one we have — but only if it has at
    // least one substantive orch control field. Domain JSON records (career
    // pipeline entries, trading alerts, etc.) have a `status` field that can
    // be deserialized into AgentResponse, but they carry no orch fields like
    // `summary`, `accomplished`, `files`, etc. Returning them would silently
    // promote a domain value (e.g. "inbound-not-submitted") as the task status.
    if let Some((resp, raw_candidate)) = best_candidate {
        if has_substantive_fields(&raw_candidate) {
            return Ok(resp);
        }
    }

    if saw_jsonish_candidate {
        if let Some(err) = last_err {
            return Err(err);
        }
    }

    if let Some(err) = last_err {
        return Err(err);
    }

    anyhow::bail!(
        "agent output is not valid JSON or a supported JSON wrapper: {}",
        raw.trim()
    )
}

/// Map known non-canonical agent statuses to their canonical equivalents.
///
/// Agents sometimes return statuses like `"ok"`, `"success"`, or `"completed"`
/// that are semantically equivalent to `"done"`. Normalizing them here ensures
/// that `classify_run_outcome` correctly classifies the run as success and
/// prevents opaque `"no error info available"` failures when an agent returns
/// a valid but non-canonical status.
///
/// Unknown statuses that read as completions normalize to done via the shared
/// heuristic in `status_heuristics`; the rest stay as-is for the audit trail.
fn normalize_status(mut resp: AgentResponse) -> AgentResponse {
    let normalized = resp.status.to_ascii_lowercase();
    if normalized.starts_with("pushed to ") || normalized.starts_with("pushed ") {
        resp.status = "done".to_string();
        return resp;
    }

    resp.status = match normalized.as_str() {
        // Canonical completion statuses.
        // Some agents return `complete` or `no_changes_needed` to indicate
        // a successful run with no further edits required — treat them as
        // `done` so the run is classified as successful.
        "done"
        | "completed"
        | "complete"
        | "skipped"
        | "skip"
        | "ok"
        | "success"
        | "noop"
        | "green"
        | "no_changes_needed"
        | "no_trades_no_positions"
        | "no_setups"
        | "changes_made"
        | "change_made"
        | "changes_pushed"
        | "pushed"
        | "acknowledged"
        | "flat"
        | "no_action"
        | "no_action_needed"
        | "missed"
        | "changes_addressed"
        | "changes_requested_addressed"
        | "review_addressed"
        | "already_finalized_in_attempt_1"
        | "alert"
        | "alerts"
        | "alerts_sent"
        | "not_configured"
        | "healthy"
        | "alerts_fired"
        | "verified"
        | "fixed" => "done".to_string(),
        // Canonical progress statuses.
        // `partial` is used by some models to indicate partial progress —
        // treat it as in_progress so the task remains open for follow-up.
        "in_progress" | "running" | "partial" | "waiting" => "in_progress".to_string(),
        "in_review" | "reviewing" => "in_review".to_string(),
        "needs_review" | "pending_review" | "ready_for_review" => "needs_review".to_string(),
        // Canonical error statuses.
        "blocked" | "error" | "failed" | "failure" => "blocked".to_string(),
        // Passthrough: known canonical statuses (already canonical, keep as-is).
        s if s == "new"
            || s == "routed"
            || s == "in_progress"
            || s == "done"
            || s == "blocked"
            || s == "in_review"
            || s == "needs_review" =>
        {
            s.to_string()
        }
        // Unknown status reading as completion, done if the response carries orch
        // fields — domain records without them stay non-canonical and get rejected (#3336)
        _ if !resp.summary.is_empty()
            || !resp.accomplished.is_empty()
            || !resp.files.is_empty()
            || resp.error.is_some() =>
        {
            if crate::status_heuristics::status_looks_like_descriptive_completion(&normalized) {
                "done".to_string()
            } else {
                resp.status.clone()
            }
        }
        _ => resp.status.clone(),
    };
    resp
}

/// Extract the first JSON code block from markdown.
fn extract_json_block(text: &str) -> Option<String> {
    let start = text.find("```json")?;
    let fence_end = start + "```json".len();
    let remainder = &text[fence_end..];

    // Find key positions after the opening fence.
    let newline_pos = remainder.find('\n');
    let json_start_pos = remainder.find(['{', '[']);
    let first_nl = newline_pos.unwrap_or(usize::MAX);

    // Decide fenced vs inline:
    // - If a JSON delimiter ('{' or '[') appears before the first newline, treat as inline.
    // - If there is no newline at all, treat as inline.
    // - Otherwise, treat as a standard fenced block.
    let is_inline = json_start_pos.is_some_and(|js| js < first_nl) || newline_pos.is_none();

    if is_inline {
        // Inline: closing fence may appear anywhere on the same logical line.
        // Using find("```") here is safe because the JSON start already appeared
        // before any newline, so the closing fence is on the same line too.
        let closing_fence_pos = remainder.find("```")?;
        let content_start = remainder
            .char_indices()
            .find(|(_, ch)| !ch.is_whitespace())
            .map_or(fence_end, |(idx, _)| fence_end + idx);
        let end = fence_end + closing_fence_pos;
        if content_start > end {
            return None;
        }
        Some(text[content_start..end].to_string())
    } else {
        // Fenced block: content begins after the first newline.
        // The closing fence must be anchored to a line boundary (\n```) to avoid
        // false positives from triple-backticks inside JSON string values.
        let nl = newline_pos?;
        let content_start = fence_end + nl + 1;
        let content = &text[content_start..];
        let closing_pos = find_closing_fence_at_line_boundary(content)?;
        Some(text[content_start..content_start + closing_pos].to_string())
    }
}

/// Find the position (within `content`) where a ``` closing fence starts, anchored to a
/// line boundary.  Returns the byte offset of the leading ``` so that `content[..pos]`
/// is everything before the closing fence (including the preceding newline).
fn find_closing_fence_at_line_boundary(content: &str) -> Option<usize> {
    // Edge case: content itself begins with the closing fence (no preceding content).
    if content.starts_with("```") {
        return Some(0);
    }
    // Scan for \n``` pattern — the closing fence must start at the beginning of a line.
    let mut search_from = 0;
    while let Some(nl_idx) = content[search_from..].find('\n') {
        let after_nl = search_from + nl_idx + 1;
        if content[after_nl..].starts_with("```") {
            // Return the position of the ``` (after the \n).
            // The content slice up to this point includes the \n, matching Markdown semantics.
            return Some(after_nl);
        }
        search_from = after_nl;
    }
    None
}

/// Returns true if the raw JSON candidate has at least one substantive orch control field.
///
/// Operates on the raw string (not the deserialized `AgentResponse`) so that fields like
/// `output` and `message` — which `map_generic_response` maps to `summary` but which are
/// not struct fields on `AgentResponse` and are silently ignored by direct deserialization —
/// are correctly treated as substantive. This prevents domain JSON records (career pipeline
/// entries, trading alerts, etc.) whose only orch-like field is `status` from being promoted
/// as the task status.
fn has_substantive_fields(raw: &str) -> bool {
    if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(raw) {
        return SUBSTANTIVE_RESPONSE_FIELDS
            .iter()
            .any(|key| obj.contains_key(*key))
            || obj.contains_key("error");
    }
    false
}

/// Fields that indicate a JSON blob is an AgentResponse (higher score = better match).
const AGENT_RESPONSE_FIELDS: &[&str] = &[
    "\"status\"",
    "\"summary\"",
    "\"accomplished\"",
    "\"remaining\"",
    "\"files\"",
    "\"learnings\"",
    "\"delegations\"",
    "\"error\"",
];

fn agent_response_score(blob: &str) -> usize {
    AGENT_RESPONSE_FIELDS
        .iter()
        .filter(|&&f| blob.contains(f))
        .count()
}

fn json_candidates(raw: &str) -> Vec<String> {
    let mut candidates = Vec::new();

    if let Some(block) = extract_json_block(raw) {
        candidates.push(block);
    }

    // Collect ALL balanced JSON blobs and sort by how much they look like an
    // AgentResponse, so the real response is tried before telemetry blobs.
    let mut all_json = extract_all_balanced_json(raw);
    all_json.sort_by_key(|b| std::cmp::Reverse(agent_response_score(b)));

    for candidate in all_json {
        if !candidates.iter().any(|existing| existing == &candidate) {
            candidates.push(candidate);
        }
    }

    candidates.push(raw.to_string());
    candidates
}

fn parse_candidate(candidate: &str) -> anyhow::Result<AgentResponse> {
    if let Ok(resp) = serde_json::from_str::<AgentResponse>(candidate) {
        return Ok(resp);
    }

    let repaired = repair_json_like(candidate);
    if repaired != candidate {
        if let Ok(resp) = serde_json::from_str::<AgentResponse>(&repaired) {
            return Ok(resp);
        }
    }

    if let Ok(val) = serde_json::from_str::<serde_json::Value>(candidate) {
        return map_generic_response(&val);
    }

    if repaired != candidate {
        if let Ok(val) = serde_json::from_str::<serde_json::Value>(&repaired) {
            return map_generic_response(&val);
        }
    }

    anyhow::bail!("invalid agent response candidate")
}

/// Scan `text` and return every top-level balanced JSON object or array found.
fn extract_all_balanced_json(text: &str) -> Vec<String> {
    let mut results = Vec::new();
    let mut pos = 0;

    while pos < text.len() {
        // Find the next `{` or `[` starting from `pos`.
        let Some(start_offset) = text[pos..]
            .char_indices()
            .find(|(_, ch)| matches!(ch, '{' | '['))
            .map(|(idx, _)| idx)
        else {
            break;
        };
        let start = pos + start_offset;

        let mut stack = Vec::new();
        let mut in_string = false;
        let mut escape = false;
        let mut end: Option<usize> = None;

        for (idx, ch) in text[start..].char_indices() {
            let abs = start + idx;
            if in_string {
                if escape {
                    escape = false;
                    continue;
                }
                match ch {
                    '\\' => escape = true,
                    '"' => in_string = false,
                    _ => {}
                }
                continue;
            }

            match ch {
                '"' => in_string = true,
                '{' | '[' => stack.push(ch),
                '}' => {
                    if stack.pop() != Some('{') {
                        break; // malformed — skip past this `{`
                    }
                    if stack.is_empty() {
                        end = Some(abs);
                        break;
                    }
                }
                ']' => {
                    if stack.pop() != Some('[') {
                        break; // malformed — skip past this `[`
                    }
                    if stack.is_empty() {
                        end = Some(abs);
                        break;
                    }
                }
                _ => {}
            }
        }

        if let Some(end_idx) = end {
            results.push(text[start..=end_idx].to_string());
            pos = end_idx + 1;
        } else {
            // No closing brace found — skip past the opening character.
            pos = start + 1;
        }
    }

    results
}

fn repair_json_like(text: &str) -> String {
    let mut repaired = text.trim().replace(['“', '”'], "\"");
    repaired = repaired.replace(['‘', '’'], "'");
    repaired = TRAILING_COMMA_RE.replace_all(&repaired, "$1").into_owned();

    let mut stack = Vec::new();
    let mut in_string = false;
    let mut escape = false;

    for ch in repaired.chars() {
        if in_string {
            if escape {
                escape = false;
                continue;
            }
            match ch {
                '\\' => escape = true,
                '"' => in_string = false,
                _ => {}
            }
            continue;
        }

        match ch {
            '"' => in_string = true,
            '{' => stack.push('}'),
            '[' => stack.push(']'),
            '}' | ']' => {
                if let Some(expected) = stack.last().copied() {
                    if expected == ch {
                        stack.pop();
                    }
                }
            }
            _ => {}
        }
    }

    while let Some(ch) = stack.pop() {
        repaired.push(ch);
    }

    repaired
}

/// Fields that indicate a substantive agent response (not just a status blob).
/// NOTE: We don't include "output" or "message" here because those are
/// handled via summary extraction in map_generic_response. This list is for
/// validating that the payload has meaningful agent response structure beyond
/// just a status field.
const SUBSTANTIVE_RESPONSE_FIELDS: &[&str] = &[
    "summary",
    "message",
    "accomplished",
    "remaining",
    "files",
    "files_changed",
    "files_modified",
    "learnings",
    "delegations",
    "output",
    "pr_url",
    "report",
];

/// Infer a canonical completion status from known non-standard completion envelope shapes.
///
/// Claude agents sometimes return JSON without a `status`/`result` field, using
/// alternative completion indicators instead. This function maps those shapes to
/// a canonical status so they are not misclassified as `InvalidResponse`.
///
/// Recognized patterns (all map to `"done"`):
/// - `{"success": true, ...}` — explicit success boolean
/// - `{"type": "task_complete", ...}` — explicit task_complete type string
/// - `{"tests_passed": true, ...}` — tests-passed completion signal
fn infer_completion_status(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
    // success: true
    if obj.get("success").and_then(|v| v.as_bool()) == Some(true) {
        return Some("done".to_string());
    }
    // type: "task_complete"
    if obj.get("type").and_then(|v| v.as_str()) == Some("task_complete") {
        return Some("done".to_string());
    }
    // ok: true
    if obj.get("ok").and_then(|v| v.as_bool()) == Some(true) {
        return Some("done".to_string());
    }
    // tests_passed: true
    if obj.get("tests_passed").and_then(|v| v.as_bool()) == Some(true) {
        return Some("done".to_string());
    }
    None
}

/// Map a generic JSON object to AgentResponse.
///
/// Requires stricter shape checking: the object must have:
/// 1. A status/result field (or a recognized completion envelope shape)
/// 2. AND at least one substantive field OR an error field
///
/// This prevents acceptance of non-response JSON blobs (telemetry, tool outputs)
/// that happen to contain a status field.
fn map_generic_response(val: &serde_json::Value) -> anyhow::Result<AgentResponse> {
    let obj = val.as_object().context("expected JSON object")?;

    // Check for status or result field first; fall back to known completion envelopes.
    let status = obj
        .get("status")
        .or_else(|| obj.get("result"))
        .and_then(|v| v.as_str())
        .map(String::from)
        .or_else(|| infer_completion_status(obj))
        .context("agent response is missing required 'status' or 'result' field")?;

    // Require at least one substantive field OR an error field.
    // This prevents accepting telemetry/event JSON blobs that happen to have
    // a status field but are not actual agent responses.
    let has_substantive_field = SUBSTANTIVE_RESPONSE_FIELDS
        .iter()
        .any(|key| obj.contains_key(*key));
    let has_error = obj.contains_key("error");

    if !has_substantive_field && !has_error {
        anyhow::bail!("JSON object lacks substantive response fields");
    }

    let summary = obj
        .get("summary")
        .or_else(|| obj.get("message"))
        .or_else(|| obj.get("output"))
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();

    let accomplished = extract_string_array(obj.get("accomplished"));
    let remaining = extract_string_array(obj.get("remaining"));
    let files = {
        let f = extract_string_array(obj.get("files"));
        if !f.is_empty() {
            f
        } else {
            let fc = extract_string_array(obj.get("files_changed"));
            if !fc.is_empty() {
                fc
            } else {
                extract_string_array(obj.get("files_modified"))
            }
        }
    };
    let error = obj.get("error").and_then(|v| v.as_str()).map(String::from);
    let learnings = extract_string_array(obj.get("learnings"));

    // Extract token counts if available
    let input_tokens = extract_u64(obj.get("input_tokens"))
        .or_else(|| extract_u64(obj.get("tokens_input")))
        .or_else(|| extract_usage_tokens(obj.get("usage"), true));
    let output_tokens = extract_u64(obj.get("output_tokens"))
        .or_else(|| extract_u64(obj.get("tokens_output")))
        .or_else(|| extract_usage_tokens(obj.get("usage"), false));

    // Extract delegations. If the `delegations` field is present but malformed,
    // return an error instead of silently ignoring it so callers can surface
    // and debug malformed agent output.
    let delegations = if let Some(v) = obj.get("delegations") {
        serde_json::from_value::<Vec<Delegation>>(v.clone())
            .with_context(|| format!("invalid delegations field: {}", v))?
    } else {
        Vec::new()
    };

    Ok(AgentResponse {
        status,
        summary,
        accomplished,
        remaining,
        files,
        error,
        input_tokens,
        output_tokens,
        learnings,
        delegations,
    })
}

/// Extract a u64 from a JSON value.
fn extract_u64(val: Option<&serde_json::Value>) -> Option<u64> {
    val.and_then(|v| v.as_u64())
}

/// Extract tokens from a usage object (common in OpenAI-compatible APIs).
fn extract_usage_tokens(usage: Option<&serde_json::Value>, is_input: bool) -> Option<u64> {
    let obj = usage?.as_object()?;
    let key = if is_input {
        "input_tokens"
    } else {
        "output_tokens"
    };
    obj.get(key).and_then(|v| v.as_u64())
}

fn extract_string_array(val: Option<&serde_json::Value>) -> Vec<String> {
    val.and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_direct_json() {
        let input = r#"{"status":"done","summary":"Fixed bug","accomplished":["fixed it"],"remaining":[],"files":["src/main.rs"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "Fixed bug");
        assert_eq!(resp.accomplished, vec!["fixed it"]);
        assert_eq!(resp.files, vec!["src/main.rs"]);
        assert!(resp.error.is_none());
    }

    #[test]
    fn parse_json_in_markdown_block() {
        let input = r#"Here is the result:

```json
{"status":"in_progress","summary":"Working on it","accomplished":[],"remaining":["finish tests"],"files":[]}
```

Done.
"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "in_progress");
        assert_eq!(resp.remaining, vec!["finish tests"]);
    }

    #[test]
    fn parse_generic_json_with_different_field_names() {
        let input = r#"{"result":"done","message":"All good","files":["a.rs","b.rs"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "All good");
        assert_eq!(resp.files, vec!["a.rs", "b.rs"]);
    }

    #[test]
    fn parse_with_error_field() {
        let input = r#"{"status":"blocked","summary":"Cannot proceed","accomplished":[],"remaining":[],"files":[],"error":"missing dependency"}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "blocked");
        assert_eq!(resp.error, Some("missing dependency".to_string()));
    }

    #[test]
    fn parse_fallback_raw_text() {
        let input = "This is just plain text output from the agent.";
        let err = parse(input).unwrap_err();
        assert!(err
            .to_string()
            .contains("agent output is not valid JSON or a supported JSON wrapper"));
    }

    #[test]
    fn parse_malformed_json_block_returns_error() {
        let input = r#"Here is the result:

```json
{"status":"done","summary":"missing brace"
```
"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "missing brace");
    }

    #[test]
    fn parse_json_with_trailing_comma() {
        let input =
            r#"{"status":"done","summary":"Fixed","accomplished":[],"remaining":[],"files":[],}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "Fixed");
    }

    #[test]
    fn parse_json_embedded_in_commentary() {
        let input = r#"Done:
{"status":"in_progress","summary":"working","accomplished":[],"remaining":["tests"],"files":[]}
Thanks"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "in_progress");
        assert_eq!(resp.remaining, vec!["tests"]);
    }

    #[test]
    fn extract_json_block_from_markdown() {
        let text = "prefix\n```json\n{\"key\":\"value\"}\n```\nsuffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"key\":\"value\"}\n");
    }

    #[test]
    fn extract_json_block_inline_fence() {
        let text = "prefix\n```json{\"key\":\"value\"}```\nsuffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"key\":\"value\"}");
    }

    #[test]
    fn extract_json_block_with_info_string() {
        let text = "prefix\n```json some-meta\n{\"key\":\"value\"}\n```\nsuffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"key\":\"value\"}\n");
    }

    #[test]
    fn extract_json_block_with_info_string_and_whitespace() {
        let text = "prefix\n```json   some-meta   \n{\"key\":\"value\"}\n```\nsuffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"key\":\"value\"}\n");
    }

    #[test]
    fn extract_json_block_inline_with_internal_newline() {
        // Inline JSON that contains a newline inside the JSON value
        let text = "prefix\n```json{\"key\":\"value\\nwith newline\"}```\nsuffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, r#"{"key":"value\nwith newline"}"#);
    }

    #[test]
    fn extract_json_block_inline_with_whitespace_newline() {
        // Inline JSON that contains a newline as whitespace between key and value
        let text = "prefix\n```json{\"key\":\n\"value\"}```\nsuffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"key\":\n\"value\"}");
    }

    #[test]
    fn extract_json_block_missing_closing_fence() {
        let text = "prefix\n```json{\"key\":\"value\"}\n";
        assert!(extract_json_block(text).is_none());
    }

    #[test]
    fn extract_json_block_array_inline() {
        let text = "prefix\n```json[1,2,3]```\nsuffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "[1,2,3]");
    }

    #[test]
    fn extract_json_block_multiple_fences() {
        let text = "prefix\n```json{\"first\":1}``` middle\n```json{\"second\":2}``` suffix";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"first\":1}");
    }

    #[test]
    fn extract_json_block_missing_returns_none() {
        assert!(extract_json_block("no code block here").is_none());
    }

    #[test]
    fn extract_string_array_from_json() {
        let val: serde_json::Value = serde_json::json!(["a", "b", "c"]);
        let result = extract_string_array(Some(&val));
        assert_eq!(result, vec!["a", "b", "c"]);
    }

    #[test]
    fn extract_string_array_none() {
        let result = extract_string_array(None);
        assert!(result.is_empty());
    }

    #[test]
    fn parse_empty_object() {
        let input = "{}";
        let err = parse(input).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("agent response is missing required")
                || msg.contains("JSON object lacks substantive"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn parse_with_learnings() {
        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"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "needs_review");
        assert_eq!(resp.summary, "Need to fix imports");
        assert_eq!(resp.learnings.len(), 2);
        assert_eq!(resp.learnings[0], "Use std::sync::Arc for shared state");
        assert_eq!(resp.learnings[1], "Check imports before committing");
    }

    #[test]
    fn parse_learnings_empty_by_default() {
        let input = r#"{"status":"done","summary":"Completed","accomplished":["done"],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert!(resp.learnings.is_empty());
    }

    #[test]
    fn parse_with_delegations() {
        let input = r#"{
            "status": "blocked",
            "summary": "Task requires subtasks",
            "accomplished": ["Analyzed requirements"],
            "remaining": ["Waiting on subtasks"],
            "files_changed": [],
            "blockers": ["Waiting on delegated subtasks"],
            "reason": "Decomposed into subtasks",
            "delegations": [
                {"title": "Implement login API", "body": "Create POST /api/login", "labels": ["backend"]},
                {"title": "Add login form", "body": "Create React login component", "labels": ["frontend"]}
            ]
        }"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "blocked");
        assert_eq!(resp.delegations.len(), 2);
        assert_eq!(resp.delegations[0].title, "Implement login API");
        assert_eq!(resp.delegations[0].body, "Create POST /api/login");
        assert_eq!(resp.delegations[0].labels, vec!["backend"]);
        assert_eq!(resp.delegations[1].title, "Add login form");
        assert_eq!(resp.delegations[1].labels, vec!["frontend"]);
    }

    #[test]
    fn parse_delegations_empty_by_default() {
        let input =
            r#"{"status":"done","summary":"Done","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert!(resp.delegations.is_empty());
    }

    #[test]
    fn parse_delegations_without_labels() {
        let input = r#"{
            "status": "blocked",
            "summary": "Delegating",
            "delegations": [
                {"title": "Subtask", "body": "Do the thing"}
            ]
        }"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.delegations.len(), 1);
        assert_eq!(resp.delegations[0].title, "Subtask");
        assert!(resp.delegations[0].labels.is_empty());
    }

    #[test]
    fn parse_response_after_telemetry_json() {
        // Telemetry JSON appears before the actual response JSON.
        // The parser must skip the telemetry blob and find the real response.
        let input = r#"Processing...
{"type":"telemetry","message":"starting task","tokens":42}
Some output here.
{"status":"done","summary":"Fixed the bug","accomplished":["patched parser"],"remaining":[],"files":["src/parser.rs"]}
"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "Fixed the bug");
        assert_eq!(resp.files, vec!["src/parser.rs"]);
    }

    // --- Edge-case tests for line-boundary fence extraction ---

    #[test]
    fn extract_json_block_backticks_inside_json_string() {
        // Triple-backticks appear inside a JSON string value.
        // The old code would stop at the first ``` (inside the string).
        // The new code must find the real closing fence at a line boundary.
        let text = "```json\n{\"code\":\"example: ```snippet``` end\"}\n```\n";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"code\":\"example: ```snippet``` end\"}\n");
    }

    #[test]
    fn extract_json_block_backticks_inside_json_string_parse_roundtrip() {
        // Full parse roundtrip: fenced block with backticks inside a JSON string value.
        let input = "```json\n{\"status\":\"done\",\"summary\":\"use ```code``` here\"}\n```\n";
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "use ```code``` here");
    }

    #[test]
    fn extract_json_block_inline_with_embedded_backtick_pairs() {
        // Inline JSON: embedded backticks that are NOT triple should not confuse the parser.
        let text = "```json{\"key\":\"a`b``c\"}```\n";
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, "{\"key\":\"a`b``c\"}");
    }

    #[test]
    fn parse_mixed_telemetry_and_fenced_response() {
        // Telemetry JSON with backtick sequences followed by a fenced agent response.
        let input = r#"Processing...
{"type":"log","msg":"calling tool: ```bash```"}
```json
{"status":"done","summary":"fixed it","accomplished":["patched"],"remaining":[],"files":["src/x.rs"]}
```
"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "fixed it");
        assert_eq!(resp.files, vec!["src/x.rs"]);
    }

    #[test]
    fn parse_response_from_ndjson_multiple_objects() {
        // NDJSON stream: only the last object has `status` — must select it.
        let input = r#"{"type":"text","text":"Working on it..."}
{"type":"tool_call","name":"bash","input":{"command":"cargo test"}}
{"type":"tool_result","output":"test passed"}
{"status":"needs_review","summary":"All tests pass","accomplished":["fixed bug"],"remaining":[],"files":["src/lib.rs"]}
"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "needs_review");
        assert_eq!(resp.summary, "All tests pass");
        assert_eq!(resp.files, vec!["src/lib.rs"]);
    }

    // ── normalize_status tests ────────────────────────────────────────────────

    #[test]
    fn parse_normalizes_ok_alias_to_done() {
        let input =
            r#"{"status":"ok","summary":"all good","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_success_alias_to_done() {
        let input = r#"{"status":"success","summary":"fixed it","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_completed_alias_to_done() {
        let input = r#"{"status":"completed","summary":"done","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_missed_alias_to_done_case_insensitive() {
        let input = r#"{"status":"MISSED","summary":"no setups found","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_changes_addressed_alias_to_done() {
        let input = r#"{"status":"changes_addressed","summary":"feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_changes_requested_addressed_alias_to_done() {
        let input = r#"{"status":"changes_requested_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_review_addressed_alias_to_done() {
        let input = r#"{"status":"review_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_already_finalized_in_attempt_1_alias_to_done() {
        let input = r#"{"status":"already_finalized_in_attempt_1","summary":"work already done in attempt 1","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_noop_alias_to_done() {
        // noop / green / alerts_sent → done, matching #3273 alias map.
        let input = r#"{"status":"noop","summary":"nothing to do","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_green_alias_to_done() {
        let input = r#"{"status":"green","summary":"all passing","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_alerts_sent_alias_to_done() {
        let input = r#"{"status":"alerts_sent","summary":"notifications dispatched","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_verified_alias_to_done() {
        let input = r#"{"status":"verified","summary":"result verified correct","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_fixed_alias_to_done() {
        let input = r#"{"status":"fixed","summary":"bug has been fixed","accomplished":["fixed bug"],"remaining":[],"files":["src/parser.rs"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_alerts_fired_alias_to_done() {
        let input = r#"{"status":"alerts_fired","summary":"alerts dispatched","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_waiting_alias_to_in_progress() {
        // waiting → in_progress (distinct from running/partial in the alias map).
        let input = r#"{"status":"waiting","summary":"blocked on upstream","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "in_progress");
    }

    #[test]
    fn parse_normalizes_changes_pushed_alias_to_done() {
        let input = r#"{"status":"changes_pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_bare_pushed_alias_to_done() {
        let input = r#"{"status":"pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_dynamic_pushed_to_branch_status_to_done() {
        let input = r#"{"status":"pushed to internal-155195-weekly-review-patterns-progress-and-next","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_normalizes_running_alias_to_in_progress() {
        let input = r#"{"status":"running","summary":"working","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "in_progress");
    }

    #[test]
    fn parse_normalizes_reviewing_alias_to_in_review() {
        let input = r#"{"status":"reviewing","summary":"reviewing","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "in_review");
    }

    #[test]
    fn parse_normalizes_pending_review_alias_to_needs_review() {
        let input = r#"{"status":"pending_review","summary":"ready","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "needs_review");
    }

    #[test]
    fn parse_normalizes_error_alias_to_blocked() {
        let input =
            r#"{"status":"error","summary":"failed","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "blocked");
    }

    #[test]
    fn parse_normalizes_failure_alias_to_blocked() {
        let input = r#"{"status":"failure","summary":"could not complete","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "blocked");
    }

    #[test]
    fn parse_keeps_canonical_statuses_unchanged() {
        for status in &[
            "new",
            "routed",
            "in_progress",
            "done",
            "blocked",
            "in_review",
            "needs_review",
        ] {
            let input = format!(
                r#"{{"status":"{status}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
            );
            let resp = parse(&input).unwrap();
            assert_eq!(
                resp.status, *status,
                "canonical status '{status}' should be unchanged"
            );
        }
    }

    #[test]
    fn parse_keeps_non_canonical_status_as_is_for_audit_trail() {
        // Non-canonical statuses that are not in the alias map are kept as-is
        // so operators can identify which statuses need to be added to normalize_status.
        let input = r#"{"status":"fix_deployed","summary":"deployed","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        // Kept as-is (not normalized away) so build_run_audit can report it explicitly.
        assert_eq!(resp.status, "fix_deployed");
    }

    #[test]
    fn parse_normalizes_heuristic_completion_alias_to_done() {
        // #3627: bare "addressed" and compound "duplicate_skipped" are not in the
        // literal alias map but clearly read as completion via the shared heuristic.
        for status in &["addressed", "duplicate_skipped"] {
            let input = format!(
                r#"{{"status":"{status}","summary":"done","accomplished":[],"remaining":[],"files":[]}}"#
            );
            let resp = parse(&input).unwrap();
            assert_eq!(
                resp.status, "done",
                "status '{status}' should normalize to done"
            );
        }
    }

    #[test]
    fn parse_fails_when_summary_present_but_status_missing() {
        // Payload contains a supported field but omits status/result — should NOT
        // be treated as done. Regression test for gh-issue-2835.
        let input = r#"{"summary":"still working","accomplished":[],"remaining":[],"files":[]}"#;
        let result = parse(input);
        assert!(
            result.is_err(),
            "missing status should error, not default to done"
        );
    }

    #[test]
    fn parse_fails_when_files_present_but_status_missing() {
        // Payload contains files/summary but no status — should not default to done.
        let input = r#"{"summary":"partial output","files":["src/foo.rs"]}"#;
        let result = parse(input);
        assert!(
            result.is_err(),
            "missing status should error, not default to done"
        );
    }

    #[test]
    fn parse_fails_when_only_message_present_without_status() {
        // Payload contains only a message field, no status — should not default to done.
        let input = r#"{"message":"working on it"}"#;
        let result = parse(input);
        assert!(
            result.is_err(),
            "missing status should error, not default to done"
        );
    }

    // ── Regression tests for gh-issue-2880 ───────────────────────────────

    #[test]
    fn parse_regression_2880_ready_for_review_normalized() {
        // ready_for_review should be normalized to needs_review.
        let input = r#"{"status":"ready_for_review","summary":"Ready for review","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "needs_review");
    }

    #[test]
    fn parse_regression_2880_status_with_output_accepted() {
        // status + output SHOULD be accepted (output is a substantive field).
        // This was already supported via summary extraction in map_generic_response.
        let input = r#"{"status":"done","output":"All tests passed"}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
    }

    #[test]
    fn parse_regression_2880_mixed_ndjson_chooses_best_candidate() {
        // In a mixed NDJSON stream:
        // - First blob: file path as status (like the bug)
        // - Later blob: proper response with done status
        // The parser should prefer the one with known status.
        let input = r#"{"status":"src/channels/discord_ws.rs","summary":"","accomplished":[],"remaining":[],"files":[]}
{"status":"done","summary":"Fixed websocket timeout","accomplished":["fixed"],"remaining":[],"files":["src/channels/discord_ws.rs"]}"#;
        let resp = parse(input).unwrap();
        // The parser now prefers known statuses, so it should pick the "done" one.
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "Fixed websocket timeout");
    }

    #[test]
    fn parse_regression_2880_normalize_aliases() {
        // Verify all status aliases are normalized correctly.
        for (input, expected) in [
            ("ok", "done"),
            ("success", "done"),
            ("completed", "done"),
            ("complete", "done"),
            ("skipped", "done"),
            ("skip", "done"),
            ("no_changes_needed", "done"),
            ("no_trades_no_positions", "done"),
            ("changes_made", "done"),
            ("changes_pushed", "done"),
            ("changes_addressed", "done"),
            ("changes_requested_addressed", "done"),
            ("fixed", "done"),
            ("running", "in_progress"),
            ("partial", "in_progress"),
            ("reviewing", "in_review"),
            ("pending_review", "needs_review"),
            ("ready_for_review", "needs_review"),
            ("error", "blocked"),
            ("failed", "blocked"),
        ] {
            let input = format!(
                r#"{{"status":"{input}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
            );
            let resp = parse(&input).unwrap();
            assert_eq!(
                resp.status, expected,
                "status '{input}' should normalize to '{expected}'"
            );
        }
    }

    #[test]
    fn parse_regression_2880_telemetry_skipped() {
        // Telemetry objects without proper response structure should be ignored.
        let input = r#"{"type":"telemetry","event":"tool_call"}
{"status":"done","summary":"Work complete","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(resp.summary, "Work complete");
    }

    // ── Regression tests for gh-issue-2979 ───────────────────────────────

    #[test]
    fn parse_regression_2979_success_true_envelope() {
        // Claude returns prose + fenced JSON with `success: true` instead of `status`.
        // Reproduces internal:148284 attempt 3.
        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.

```json
{"success": true, "summary": "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."}
```"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(
            resp.summary,
            "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."
        );
    }

    #[test]
    fn parse_regression_2979_type_task_complete_envelope() {
        // Claude returns prose + fenced JSON with `type: "task_complete"` instead of `status`.
        // Reproduces internal:147845 attempt 7.
        let input = r#"The daily trending doc is complete at md/research/2026-04-22-daily-trending.md.

```json
{
  "type": "task_complete",
  "summary": "Fetched global trending topics and saved to md/research/2026-04-22-daily-trending.md.",
  "files_changed": ["md/research/2026-04-22-daily-trending.md"]
}
```"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert!(resp.summary.contains("Fetched global trending topics"));
        assert_eq!(resp.files, vec!["md/research/2026-04-22-daily-trending.md"]);
    }

    #[test]
    fn parse_regression_2979_tests_passed_envelope() {
        // Claude returns prose + fenced JSON with `tests_passed: true` and `files_modified`.
        // Reproduces task 2955 attempt 9.
        let input = r#"PR created: gabrielkoerich/orch#2969

The fix from attempt #8 was already committed. All quality gates pass.

```json
{
  "summary": "Retry watermark persistence on store failure.",
  "files_modified": ["src/engine/review_poll.rs"],
  "pr_url": "https://github.com/gabrielkoerich/orch/pull/2969",
  "tests_passed": true,
  "quality_gates": ["cargo fmt", "cargo clippy", "cargo nextest run"]
}
```"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert!(resp.summary.contains("Retry watermark persistence"));
        assert_eq!(resp.files, vec!["src/engine/review_poll.rs"]);
    }

    #[test]
    fn parse_regression_2979_success_false_does_not_infer_done() {
        // success: false should NOT infer done — only success: true triggers inference.
        let input = r#"```json
{"success": false, "summary": "Could not complete the task."}
```"#;
        let result = parse(input);
        // success: false has no `status` and `success` is not a valid status value,
        // and infer_completion_status only fires for success=true, so this must error.
        assert!(
            result.is_err(),
            "success: false should not be treated as done"
        );
    }

    #[test]
    fn parse_regression_2979_type_other_does_not_infer_done() {
        // type: "something_else" should NOT infer done.
        let input = r#"{"type": "tool_call", "summary": "calling bash"}"#;
        let result = parse(input);
        assert!(
            result.is_err(),
            "type other than task_complete should not be treated as done"
        );
    }

    #[test]
    fn parse_regression_2979_files_modified_alias() {
        // files_modified should be picked up as the files list.
        let input = r#"{"status": "done", "summary": "done", "files_modified": ["src/foo.rs", "src/bar.rs"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.files, vec!["src/foo.rs", "src/bar.rs"]);
    }

    // ── Regression tests for gh-issue-3336 ───────────────────────────────

    #[test]
    fn parse_regression_3336_jsonl_domain_status_field_rejected() {
        // Career pipeline JSONL output. Neither record has an orch canonical status
        // and neither has any substantive orch fields — must be rejected, not returned
        // with domain status "inbound-not-submitted" as the task status.
        let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}"#;
        let err = parse(input).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("agent output is not valid JSON or a supported JSON wrapper")
                || msg.contains("JSON object lacks substantive"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn parse_regression_3336_single_domain_record_rejected() {
        // A single domain JSON record with a non-canonical status and no orch fields.
        let input = r#"{"status":"inbound-not-submitted","date":"2026-06-15","role":"Engineer","company":"Acme"}"#;
        let err = parse(input).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("agent output is not valid JSON or a supported JSON wrapper")
                || msg.contains("JSON object lacks substantive"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn parse_regression_3336_noncanonical_status_with_summary_still_accepted() {
        // A non-canonical orch status WITH a summary is a real agent response and must
        // still be accepted (not broken by the substantive-field guard).
        let input = r#"{"status":"fix_deployed","summary":"deployed to staging","accomplished":[],"remaining":[],"files":[]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "fix_deployed");
        assert_eq!(resp.summary, "deployed to staging");
    }

    #[test]
    fn parse_regression_3336_jsonl_domain_plus_orch_response_prefers_orch() {
        // JSONL output where domain records precede an actual orch response with a
        // canonical status. The parser must pick the orch response.
        let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}
{"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"]}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "done");
        assert_eq!(
            resp.summary,
            "Career radar written to md/career/2026-06-15-radar.md"
        );
        assert_eq!(resp.files, vec!["md/career/2026-06-15-radar.md"]);
    }

    #[test]
    fn parse_regression_3336_noncanonical_with_output_accepted() {
        // A non-canonical status with `output` (handled by map_generic_response but not a
        // struct field on AgentResponse) must still be accepted — not rejected with a parse
        // error. Previously `has_substantive_fields` checked the deserialized struct and
        // missed `output`, causing this to fail with "JSON object lacks substantive".
        let input = r#"{"status":"mystery_status","output":"All tests passed"}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "mystery_status");
    }

    #[test]
    fn parse_regression_3336_noncanonical_with_message_accepted() {
        // Same as above for `message` — also substantive but absent from the AgentResponse
        // struct, so direct deserialization leaves summary empty.
        let input = r#"{"status":"mystery_status","message":"All tests passed"}"#;
        let resp = parse(input).unwrap();
        assert_eq!(resp.status, "mystery_status");
    }
}