1use anyhow::Context;
8use once_cell::sync::Lazy;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::io::Read;
12
13static TRAILING_COMMA_RE: Lazy<Regex> =
14 Lazy::new(|| Regex::new(r",(\s*[}\]])").expect("trailing comma regex is valid"));
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct AgentResponse {
19 pub status: String,
20 #[serde(default)]
21 pub summary: String,
22 #[serde(default)]
23 pub accomplished: Vec<String>,
24 #[serde(default)]
25 pub remaining: Vec<String>,
26 #[serde(default, alias = "files_changed", alias = "files_modified")]
27 pub files: Vec<String>,
28 #[serde(default)]
29 pub error: Option<String>,
30 #[serde(default)]
32 pub input_tokens: Option<u64>,
33 #[serde(default)]
35 pub output_tokens: Option<u64>,
36 #[serde(default)]
38 pub learnings: Vec<String>,
39 #[serde(default)]
41 pub delegations: Vec<Delegation>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Delegation {
47 pub title: String,
48 pub body: String,
49 #[serde(default)]
50 pub labels: Vec<String>,
51}
52
53pub fn parse_and_print(path: &str) -> anyhow::Result<()> {
55 let content = if path == "-" {
56 let mut buf = String::new();
57 std::io::stdin()
58 .read_to_string(&mut buf)
59 .context("reading stdin")?;
60 buf
61 } else {
62 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
63 };
64
65 let response = parse(&content)?;
66 println!("{}", serde_json::to_string_pretty(&response)?);
67 Ok(())
68}
69
70fn status_is_known(status: &str) -> bool {
74 matches!(
75 status,
76 "new"
77 | "routed"
78 | "in_progress"
79 | "done"
80 | "completed"
81 | "ok"
82 | "success"
83 | "running"
84 | "blocked"
85 | "error"
86 | "failed"
87 | "in_review"
88 | "reviewing"
89 | "needs_review"
90 | "pending_review"
91 )
92}
93
94pub fn parse(raw: &str) -> anyhow::Result<AgentResponse> {
96 let mut last_err: Option<anyhow::Error> = None;
97 let mut saw_jsonish_candidate = false;
98 let mut best_candidate: Option<(AgentResponse, String)> = None;
102
103 for candidate in json_candidates(raw) {
104 match parse_candidate(&candidate) {
105 Ok(mut resp) => {
106 resp = normalize_status(resp);
107 if status_is_known(&resp.status) {
109 return Ok(resp);
110 }
111 if best_candidate.is_none() {
113 best_candidate = Some((resp, candidate));
114 }
115 }
116 Err(err) => {
117 if err.to_string() != "invalid agent response candidate" {
118 saw_jsonish_candidate = true;
119 last_err = Some(err);
120 }
121 }
122 }
123 }
124
125 if let Some((resp, raw_candidate)) = best_candidate {
133 if has_substantive_fields(&raw_candidate) {
134 return Ok(resp);
135 }
136 }
137
138 if saw_jsonish_candidate {
139 if let Some(err) = last_err {
140 return Err(err);
141 }
142 }
143
144 if let Some(err) = last_err {
145 return Err(err);
146 }
147
148 anyhow::bail!(
149 "agent output is not valid JSON or a supported JSON wrapper: {}",
150 raw.trim()
151 )
152}
153
154fn normalize_status(mut resp: AgentResponse) -> AgentResponse {
165 let normalized = resp.status.to_ascii_lowercase();
166 if normalized.starts_with("pushed to ") || normalized.starts_with("pushed ") {
167 resp.status = "done".to_string();
168 return resp;
169 }
170
171 resp.status = match normalized.as_str() {
172 "done"
177 | "completed"
178 | "complete"
179 | "skipped"
180 | "skip"
181 | "ok"
182 | "success"
183 | "noop"
184 | "green"
185 | "no_changes_needed"
186 | "no_trades_no_positions"
187 | "no_setups"
188 | "changes_made"
189 | "change_made"
190 | "changes_pushed"
191 | "pushed"
192 | "acknowledged"
193 | "flat"
194 | "no_action"
195 | "no_action_needed"
196 | "missed"
197 | "changes_addressed"
198 | "changes_requested_addressed"
199 | "review_addressed"
200 | "already_finalized_in_attempt_1"
201 | "alert"
202 | "alerts"
203 | "alerts_sent"
204 | "not_configured"
205 | "healthy"
206 | "alerts_fired"
207 | "verified"
208 | "fixed" => "done".to_string(),
209 "in_progress" | "running" | "partial" | "waiting" => "in_progress".to_string(),
213 "in_review" | "reviewing" => "in_review".to_string(),
214 "needs_review" | "pending_review" | "ready_for_review" => "needs_review".to_string(),
215 "blocked" | "error" | "failed" | "failure" => "blocked".to_string(),
217 s if s == "new"
219 || s == "routed"
220 || s == "in_progress"
221 || s == "done"
222 || s == "blocked"
223 || s == "in_review"
224 || s == "needs_review" =>
225 {
226 s.to_string()
227 }
228 _ if !resp.summary.is_empty()
231 || !resp.accomplished.is_empty()
232 || !resp.files.is_empty()
233 || resp.error.is_some() =>
234 {
235 if crate::status_heuristics::status_looks_like_descriptive_completion(&normalized) {
236 "done".to_string()
237 } else {
238 resp.status.clone()
239 }
240 }
241 _ => resp.status.clone(),
242 };
243 resp
244}
245
246fn extract_json_block(text: &str) -> Option<String> {
248 let start = text.find("```json")?;
249 let fence_end = start + "```json".len();
250 let remainder = &text[fence_end..];
251
252 let newline_pos = remainder.find('\n');
254 let json_start_pos = remainder.find(['{', '[']);
255 let first_nl = newline_pos.unwrap_or(usize::MAX);
256
257 let is_inline = json_start_pos.is_some_and(|js| js < first_nl) || newline_pos.is_none();
262
263 if is_inline {
264 let closing_fence_pos = remainder.find("```")?;
268 let content_start = remainder
269 .char_indices()
270 .find(|(_, ch)| !ch.is_whitespace())
271 .map_or(fence_end, |(idx, _)| fence_end + idx);
272 let end = fence_end + closing_fence_pos;
273 if content_start > end {
274 return None;
275 }
276 Some(text[content_start..end].to_string())
277 } else {
278 let nl = newline_pos?;
282 let content_start = fence_end + nl + 1;
283 let content = &text[content_start..];
284 let closing_pos = find_closing_fence_at_line_boundary(content)?;
285 Some(text[content_start..content_start + closing_pos].to_string())
286 }
287}
288
289fn find_closing_fence_at_line_boundary(content: &str) -> Option<usize> {
293 if content.starts_with("```") {
295 return Some(0);
296 }
297 let mut search_from = 0;
299 while let Some(nl_idx) = content[search_from..].find('\n') {
300 let after_nl = search_from + nl_idx + 1;
301 if content[after_nl..].starts_with("```") {
302 return Some(after_nl);
305 }
306 search_from = after_nl;
307 }
308 None
309}
310
311fn has_substantive_fields(raw: &str) -> bool {
320 if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(raw) {
321 return SUBSTANTIVE_RESPONSE_FIELDS
322 .iter()
323 .any(|key| obj.contains_key(*key))
324 || obj.contains_key("error");
325 }
326 false
327}
328
329const AGENT_RESPONSE_FIELDS: &[&str] = &[
331 "\"status\"",
332 "\"summary\"",
333 "\"accomplished\"",
334 "\"remaining\"",
335 "\"files\"",
336 "\"learnings\"",
337 "\"delegations\"",
338 "\"error\"",
339];
340
341fn agent_response_score(blob: &str) -> usize {
342 AGENT_RESPONSE_FIELDS
343 .iter()
344 .filter(|&&f| blob.contains(f))
345 .count()
346}
347
348fn json_candidates(raw: &str) -> Vec<String> {
349 let mut candidates = Vec::new();
350
351 if let Some(block) = extract_json_block(raw) {
352 candidates.push(block);
353 }
354
355 let mut all_json = extract_all_balanced_json(raw);
358 all_json.sort_by_key(|b| std::cmp::Reverse(agent_response_score(b)));
359
360 for candidate in all_json {
361 if !candidates.iter().any(|existing| existing == &candidate) {
362 candidates.push(candidate);
363 }
364 }
365
366 candidates.push(raw.to_string());
367 candidates
368}
369
370fn parse_candidate(candidate: &str) -> anyhow::Result<AgentResponse> {
371 if let Ok(resp) = serde_json::from_str::<AgentResponse>(candidate) {
372 return Ok(resp);
373 }
374
375 let repaired = repair_json_like(candidate);
376 if repaired != candidate {
377 if let Ok(resp) = serde_json::from_str::<AgentResponse>(&repaired) {
378 return Ok(resp);
379 }
380 }
381
382 if let Ok(val) = serde_json::from_str::<serde_json::Value>(candidate) {
383 return map_generic_response(&val);
384 }
385
386 if repaired != candidate {
387 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&repaired) {
388 return map_generic_response(&val);
389 }
390 }
391
392 anyhow::bail!("invalid agent response candidate")
393}
394
395fn extract_all_balanced_json(text: &str) -> Vec<String> {
397 let mut results = Vec::new();
398 let mut pos = 0;
399
400 while pos < text.len() {
401 let Some(start_offset) = text[pos..]
403 .char_indices()
404 .find(|(_, ch)| matches!(ch, '{' | '['))
405 .map(|(idx, _)| idx)
406 else {
407 break;
408 };
409 let start = pos + start_offset;
410
411 let mut stack = Vec::new();
412 let mut in_string = false;
413 let mut escape = false;
414 let mut end: Option<usize> = None;
415
416 for (idx, ch) in text[start..].char_indices() {
417 let abs = start + idx;
418 if in_string {
419 if escape {
420 escape = false;
421 continue;
422 }
423 match ch {
424 '\\' => escape = true,
425 '"' => in_string = false,
426 _ => {}
427 }
428 continue;
429 }
430
431 match ch {
432 '"' => in_string = true,
433 '{' | '[' => stack.push(ch),
434 '}' => {
435 if stack.pop() != Some('{') {
436 break; }
438 if stack.is_empty() {
439 end = Some(abs);
440 break;
441 }
442 }
443 ']' => {
444 if stack.pop() != Some('[') {
445 break; }
447 if stack.is_empty() {
448 end = Some(abs);
449 break;
450 }
451 }
452 _ => {}
453 }
454 }
455
456 if let Some(end_idx) = end {
457 results.push(text[start..=end_idx].to_string());
458 pos = end_idx + 1;
459 } else {
460 pos = start + 1;
462 }
463 }
464
465 results
466}
467
468fn repair_json_like(text: &str) -> String {
469 let mut repaired = text.trim().replace(['“', '”'], "\"");
470 repaired = repaired.replace(['‘', '’'], "'");
471 repaired = TRAILING_COMMA_RE.replace_all(&repaired, "$1").into_owned();
472
473 let mut stack = Vec::new();
474 let mut in_string = false;
475 let mut escape = false;
476
477 for ch in repaired.chars() {
478 if in_string {
479 if escape {
480 escape = false;
481 continue;
482 }
483 match ch {
484 '\\' => escape = true,
485 '"' => in_string = false,
486 _ => {}
487 }
488 continue;
489 }
490
491 match ch {
492 '"' => in_string = true,
493 '{' => stack.push('}'),
494 '[' => stack.push(']'),
495 '}' | ']' => {
496 if let Some(expected) = stack.last().copied() {
497 if expected == ch {
498 stack.pop();
499 }
500 }
501 }
502 _ => {}
503 }
504 }
505
506 while let Some(ch) = stack.pop() {
507 repaired.push(ch);
508 }
509
510 repaired
511}
512
513const SUBSTANTIVE_RESPONSE_FIELDS: &[&str] = &[
519 "summary",
520 "message",
521 "accomplished",
522 "remaining",
523 "files",
524 "files_changed",
525 "files_modified",
526 "learnings",
527 "delegations",
528 "output",
529 "pr_url",
530];
531
532fn infer_completion_status(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
543 if obj.get("success").and_then(|v| v.as_bool()) == Some(true) {
545 return Some("done".to_string());
546 }
547 if obj.get("type").and_then(|v| v.as_str()) == Some("task_complete") {
549 return Some("done".to_string());
550 }
551 if obj.get("tests_passed").and_then(|v| v.as_bool()) == Some(true) {
553 return Some("done".to_string());
554 }
555 None
556}
557
558fn map_generic_response(val: &serde_json::Value) -> anyhow::Result<AgentResponse> {
567 let obj = val.as_object().context("expected JSON object")?;
568
569 let status = obj
571 .get("status")
572 .or_else(|| obj.get("result"))
573 .and_then(|v| v.as_str())
574 .map(String::from)
575 .or_else(|| infer_completion_status(obj))
576 .context("agent response is missing required 'status' or 'result' field")?;
577
578 let has_substantive_field = SUBSTANTIVE_RESPONSE_FIELDS
582 .iter()
583 .any(|key| obj.contains_key(*key));
584 let has_error = obj.contains_key("error");
585
586 if !has_substantive_field && !has_error {
587 anyhow::bail!("JSON object lacks substantive response fields");
588 }
589
590 let summary = obj
591 .get("summary")
592 .or_else(|| obj.get("message"))
593 .or_else(|| obj.get("output"))
594 .and_then(|v| v.as_str())
595 .unwrap_or("")
596 .to_string();
597
598 let accomplished = extract_string_array(obj.get("accomplished"));
599 let remaining = extract_string_array(obj.get("remaining"));
600 let files = {
601 let f = extract_string_array(obj.get("files"));
602 if !f.is_empty() {
603 f
604 } else {
605 let fc = extract_string_array(obj.get("files_changed"));
606 if !fc.is_empty() {
607 fc
608 } else {
609 extract_string_array(obj.get("files_modified"))
610 }
611 }
612 };
613 let error = obj.get("error").and_then(|v| v.as_str()).map(String::from);
614 let learnings = extract_string_array(obj.get("learnings"));
615
616 let input_tokens = extract_u64(obj.get("input_tokens"))
618 .or_else(|| extract_u64(obj.get("tokens_input")))
619 .or_else(|| extract_usage_tokens(obj.get("usage"), true));
620 let output_tokens = extract_u64(obj.get("output_tokens"))
621 .or_else(|| extract_u64(obj.get("tokens_output")))
622 .or_else(|| extract_usage_tokens(obj.get("usage"), false));
623
624 let delegations = if let Some(v) = obj.get("delegations") {
628 serde_json::from_value::<Vec<Delegation>>(v.clone())
629 .with_context(|| format!("invalid delegations field: {}", v))?
630 } else {
631 Vec::new()
632 };
633
634 Ok(AgentResponse {
635 status,
636 summary,
637 accomplished,
638 remaining,
639 files,
640 error,
641 input_tokens,
642 output_tokens,
643 learnings,
644 delegations,
645 })
646}
647
648fn extract_u64(val: Option<&serde_json::Value>) -> Option<u64> {
650 val.and_then(|v| v.as_u64())
651}
652
653fn extract_usage_tokens(usage: Option<&serde_json::Value>, is_input: bool) -> Option<u64> {
655 let obj = usage?.as_object()?;
656 let key = if is_input {
657 "input_tokens"
658 } else {
659 "output_tokens"
660 };
661 obj.get(key).and_then(|v| v.as_u64())
662}
663
664fn extract_string_array(val: Option<&serde_json::Value>) -> Vec<String> {
665 val.and_then(|v| v.as_array())
666 .map(|arr| {
667 arr.iter()
668 .filter_map(|v| v.as_str().map(String::from))
669 .collect()
670 })
671 .unwrap_or_default()
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn parse_direct_json() {
680 let input = r#"{"status":"done","summary":"Fixed bug","accomplished":["fixed it"],"remaining":[],"files":["src/main.rs"]}"#;
681 let resp = parse(input).unwrap();
682 assert_eq!(resp.status, "done");
683 assert_eq!(resp.summary, "Fixed bug");
684 assert_eq!(resp.accomplished, vec!["fixed it"]);
685 assert_eq!(resp.files, vec!["src/main.rs"]);
686 assert!(resp.error.is_none());
687 }
688
689 #[test]
690 fn parse_json_in_markdown_block() {
691 let input = r#"Here is the result:
692
693```json
694{"status":"in_progress","summary":"Working on it","accomplished":[],"remaining":["finish tests"],"files":[]}
695```
696
697Done.
698"#;
699 let resp = parse(input).unwrap();
700 assert_eq!(resp.status, "in_progress");
701 assert_eq!(resp.remaining, vec!["finish tests"]);
702 }
703
704 #[test]
705 fn parse_generic_json_with_different_field_names() {
706 let input = r#"{"result":"done","message":"All good","files":["a.rs","b.rs"]}"#;
707 let resp = parse(input).unwrap();
708 assert_eq!(resp.status, "done");
709 assert_eq!(resp.summary, "All good");
710 assert_eq!(resp.files, vec!["a.rs", "b.rs"]);
711 }
712
713 #[test]
714 fn parse_with_error_field() {
715 let input = r#"{"status":"blocked","summary":"Cannot proceed","accomplished":[],"remaining":[],"files":[],"error":"missing dependency"}"#;
716 let resp = parse(input).unwrap();
717 assert_eq!(resp.status, "blocked");
718 assert_eq!(resp.error, Some("missing dependency".to_string()));
719 }
720
721 #[test]
722 fn parse_fallback_raw_text() {
723 let input = "This is just plain text output from the agent.";
724 let err = parse(input).unwrap_err();
725 assert!(err
726 .to_string()
727 .contains("agent output is not valid JSON or a supported JSON wrapper"));
728 }
729
730 #[test]
731 fn parse_malformed_json_block_returns_error() {
732 let input = r#"Here is the result:
733
734```json
735{"status":"done","summary":"missing brace"
736```
737"#;
738 let resp = parse(input).unwrap();
739 assert_eq!(resp.status, "done");
740 assert_eq!(resp.summary, "missing brace");
741 }
742
743 #[test]
744 fn parse_json_with_trailing_comma() {
745 let input =
746 r#"{"status":"done","summary":"Fixed","accomplished":[],"remaining":[],"files":[],}"#;
747 let resp = parse(input).unwrap();
748 assert_eq!(resp.status, "done");
749 assert_eq!(resp.summary, "Fixed");
750 }
751
752 #[test]
753 fn parse_json_embedded_in_commentary() {
754 let input = r#"Done:
755{"status":"in_progress","summary":"working","accomplished":[],"remaining":["tests"],"files":[]}
756Thanks"#;
757 let resp = parse(input).unwrap();
758 assert_eq!(resp.status, "in_progress");
759 assert_eq!(resp.remaining, vec!["tests"]);
760 }
761
762 #[test]
763 fn extract_json_block_from_markdown() {
764 let text = "prefix\n```json\n{\"key\":\"value\"}\n```\nsuffix";
765 let block = extract_json_block(text).unwrap();
766 assert_eq!(block, "{\"key\":\"value\"}\n");
767 }
768
769 #[test]
770 fn extract_json_block_inline_fence() {
771 let text = "prefix\n```json{\"key\":\"value\"}```\nsuffix";
772 let block = extract_json_block(text).unwrap();
773 assert_eq!(block, "{\"key\":\"value\"}");
774 }
775
776 #[test]
777 fn extract_json_block_with_info_string() {
778 let text = "prefix\n```json some-meta\n{\"key\":\"value\"}\n```\nsuffix";
779 let block = extract_json_block(text).unwrap();
780 assert_eq!(block, "{\"key\":\"value\"}\n");
781 }
782
783 #[test]
784 fn extract_json_block_with_info_string_and_whitespace() {
785 let text = "prefix\n```json some-meta \n{\"key\":\"value\"}\n```\nsuffix";
786 let block = extract_json_block(text).unwrap();
787 assert_eq!(block, "{\"key\":\"value\"}\n");
788 }
789
790 #[test]
791 fn extract_json_block_inline_with_internal_newline() {
792 let text = "prefix\n```json{\"key\":\"value\\nwith newline\"}```\nsuffix";
794 let block = extract_json_block(text).unwrap();
795 assert_eq!(block, r#"{"key":"value\nwith newline"}"#);
796 }
797
798 #[test]
799 fn extract_json_block_inline_with_whitespace_newline() {
800 let text = "prefix\n```json{\"key\":\n\"value\"}```\nsuffix";
802 let block = extract_json_block(text).unwrap();
803 assert_eq!(block, "{\"key\":\n\"value\"}");
804 }
805
806 #[test]
807 fn extract_json_block_missing_closing_fence() {
808 let text = "prefix\n```json{\"key\":\"value\"}\n";
809 assert!(extract_json_block(text).is_none());
810 }
811
812 #[test]
813 fn extract_json_block_array_inline() {
814 let text = "prefix\n```json[1,2,3]```\nsuffix";
815 let block = extract_json_block(text).unwrap();
816 assert_eq!(block, "[1,2,3]");
817 }
818
819 #[test]
820 fn extract_json_block_multiple_fences() {
821 let text = "prefix\n```json{\"first\":1}``` middle\n```json{\"second\":2}``` suffix";
822 let block = extract_json_block(text).unwrap();
823 assert_eq!(block, "{\"first\":1}");
824 }
825
826 #[test]
827 fn extract_json_block_missing_returns_none() {
828 assert!(extract_json_block("no code block here").is_none());
829 }
830
831 #[test]
832 fn extract_string_array_from_json() {
833 let val: serde_json::Value = serde_json::json!(["a", "b", "c"]);
834 let result = extract_string_array(Some(&val));
835 assert_eq!(result, vec!["a", "b", "c"]);
836 }
837
838 #[test]
839 fn extract_string_array_none() {
840 let result = extract_string_array(None);
841 assert!(result.is_empty());
842 }
843
844 #[test]
845 fn parse_empty_object() {
846 let input = "{}";
847 let err = parse(input).unwrap_err();
848 let msg = err.to_string();
849 assert!(
850 msg.contains("agent response is missing required")
851 || msg.contains("JSON object lacks substantive"),
852 "unexpected error: {msg}"
853 );
854 }
855
856 #[test]
857 fn parse_with_learnings() {
858 let input = r#"{"status":"needs_review","summary":"Need to fix imports","accomplished":[],"remaining":["fix imports"],"files":["src/main.rs"],"learnings":["Use std::sync::Arc for shared state","Check imports before committing"]}"#;
859 let resp = parse(input).unwrap();
860 assert_eq!(resp.status, "needs_review");
861 assert_eq!(resp.summary, "Need to fix imports");
862 assert_eq!(resp.learnings.len(), 2);
863 assert_eq!(resp.learnings[0], "Use std::sync::Arc for shared state");
864 assert_eq!(resp.learnings[1], "Check imports before committing");
865 }
866
867 #[test]
868 fn parse_learnings_empty_by_default() {
869 let input = r#"{"status":"done","summary":"Completed","accomplished":["done"],"remaining":[],"files":[]}"#;
870 let resp = parse(input).unwrap();
871 assert!(resp.learnings.is_empty());
872 }
873
874 #[test]
875 fn parse_with_delegations() {
876 let input = r#"{
877 "status": "blocked",
878 "summary": "Task requires subtasks",
879 "accomplished": ["Analyzed requirements"],
880 "remaining": ["Waiting on subtasks"],
881 "files_changed": [],
882 "blockers": ["Waiting on delegated subtasks"],
883 "reason": "Decomposed into subtasks",
884 "delegations": [
885 {"title": "Implement login API", "body": "Create POST /api/login", "labels": ["backend"]},
886 {"title": "Add login form", "body": "Create React login component", "labels": ["frontend"]}
887 ]
888 }"#;
889 let resp = parse(input).unwrap();
890 assert_eq!(resp.status, "blocked");
891 assert_eq!(resp.delegations.len(), 2);
892 assert_eq!(resp.delegations[0].title, "Implement login API");
893 assert_eq!(resp.delegations[0].body, "Create POST /api/login");
894 assert_eq!(resp.delegations[0].labels, vec!["backend"]);
895 assert_eq!(resp.delegations[1].title, "Add login form");
896 assert_eq!(resp.delegations[1].labels, vec!["frontend"]);
897 }
898
899 #[test]
900 fn parse_delegations_empty_by_default() {
901 let input =
902 r#"{"status":"done","summary":"Done","accomplished":[],"remaining":[],"files":[]}"#;
903 let resp = parse(input).unwrap();
904 assert!(resp.delegations.is_empty());
905 }
906
907 #[test]
908 fn parse_delegations_without_labels() {
909 let input = r#"{
910 "status": "blocked",
911 "summary": "Delegating",
912 "delegations": [
913 {"title": "Subtask", "body": "Do the thing"}
914 ]
915 }"#;
916 let resp = parse(input).unwrap();
917 assert_eq!(resp.delegations.len(), 1);
918 assert_eq!(resp.delegations[0].title, "Subtask");
919 assert!(resp.delegations[0].labels.is_empty());
920 }
921
922 #[test]
923 fn parse_response_after_telemetry_json() {
924 let input = r#"Processing...
927{"type":"telemetry","message":"starting task","tokens":42}
928Some output here.
929{"status":"done","summary":"Fixed the bug","accomplished":["patched parser"],"remaining":[],"files":["src/parser.rs"]}
930"#;
931 let resp = parse(input).unwrap();
932 assert_eq!(resp.status, "done");
933 assert_eq!(resp.summary, "Fixed the bug");
934 assert_eq!(resp.files, vec!["src/parser.rs"]);
935 }
936
937 #[test]
940 fn extract_json_block_backticks_inside_json_string() {
941 let text = "```json\n{\"code\":\"example: ```snippet``` end\"}\n```\n";
945 let block = extract_json_block(text).unwrap();
946 assert_eq!(block, "{\"code\":\"example: ```snippet``` end\"}\n");
947 }
948
949 #[test]
950 fn extract_json_block_backticks_inside_json_string_parse_roundtrip() {
951 let input = "```json\n{\"status\":\"done\",\"summary\":\"use ```code``` here\"}\n```\n";
953 let resp = parse(input).unwrap();
954 assert_eq!(resp.status, "done");
955 assert_eq!(resp.summary, "use ```code``` here");
956 }
957
958 #[test]
959 fn extract_json_block_inline_with_embedded_backtick_pairs() {
960 let text = "```json{\"key\":\"a`b``c\"}```\n";
962 let block = extract_json_block(text).unwrap();
963 assert_eq!(block, "{\"key\":\"a`b``c\"}");
964 }
965
966 #[test]
967 fn parse_mixed_telemetry_and_fenced_response() {
968 let input = r#"Processing...
970{"type":"log","msg":"calling tool: ```bash```"}
971```json
972{"status":"done","summary":"fixed it","accomplished":["patched"],"remaining":[],"files":["src/x.rs"]}
973```
974"#;
975 let resp = parse(input).unwrap();
976 assert_eq!(resp.status, "done");
977 assert_eq!(resp.summary, "fixed it");
978 assert_eq!(resp.files, vec!["src/x.rs"]);
979 }
980
981 #[test]
982 fn parse_response_from_ndjson_multiple_objects() {
983 let input = r#"{"type":"text","text":"Working on it..."}
985{"type":"tool_call","name":"bash","input":{"command":"cargo test"}}
986{"type":"tool_result","output":"test passed"}
987{"status":"needs_review","summary":"All tests pass","accomplished":["fixed bug"],"remaining":[],"files":["src/lib.rs"]}
988"#;
989 let resp = parse(input).unwrap();
990 assert_eq!(resp.status, "needs_review");
991 assert_eq!(resp.summary, "All tests pass");
992 assert_eq!(resp.files, vec!["src/lib.rs"]);
993 }
994
995 #[test]
998 fn parse_normalizes_ok_alias_to_done() {
999 let input =
1000 r#"{"status":"ok","summary":"all good","accomplished":[],"remaining":[],"files":[]}"#;
1001 let resp = parse(input).unwrap();
1002 assert_eq!(resp.status, "done");
1003 }
1004
1005 #[test]
1006 fn parse_normalizes_success_alias_to_done() {
1007 let input = r#"{"status":"success","summary":"fixed it","accomplished":[],"remaining":[],"files":[]}"#;
1008 let resp = parse(input).unwrap();
1009 assert_eq!(resp.status, "done");
1010 }
1011
1012 #[test]
1013 fn parse_normalizes_completed_alias_to_done() {
1014 let input = r#"{"status":"completed","summary":"done","accomplished":[],"remaining":[],"files":[]}"#;
1015 let resp = parse(input).unwrap();
1016 assert_eq!(resp.status, "done");
1017 }
1018
1019 #[test]
1020 fn parse_normalizes_missed_alias_to_done_case_insensitive() {
1021 let input = r#"{"status":"MISSED","summary":"no setups found","accomplished":[],"remaining":[],"files":[]}"#;
1022 let resp = parse(input).unwrap();
1023 assert_eq!(resp.status, "done");
1024 }
1025
1026 #[test]
1027 fn parse_normalizes_changes_addressed_alias_to_done() {
1028 let input = r#"{"status":"changes_addressed","summary":"feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1029 let resp = parse(input).unwrap();
1030 assert_eq!(resp.status, "done");
1031 }
1032
1033 #[test]
1034 fn parse_normalizes_changes_requested_addressed_alias_to_done() {
1035 let input = r#"{"status":"changes_requested_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1036 let resp = parse(input).unwrap();
1037 assert_eq!(resp.status, "done");
1038 }
1039
1040 #[test]
1041 fn parse_normalizes_review_addressed_alias_to_done() {
1042 let input = r#"{"status":"review_addressed","summary":"review feedback addressed","accomplished":[],"remaining":[],"files":[]}"#;
1043 let resp = parse(input).unwrap();
1044 assert_eq!(resp.status, "done");
1045 }
1046
1047 #[test]
1048 fn parse_normalizes_already_finalized_in_attempt_1_alias_to_done() {
1049 let input = r#"{"status":"already_finalized_in_attempt_1","summary":"work already done in attempt 1","accomplished":[],"remaining":[],"files":[]}"#;
1050 let resp = parse(input).unwrap();
1051 assert_eq!(resp.status, "done");
1052 }
1053
1054 #[test]
1055 fn parse_normalizes_noop_alias_to_done() {
1056 let input = r#"{"status":"noop","summary":"nothing to do","accomplished":[],"remaining":[],"files":[]}"#;
1058 let resp = parse(input).unwrap();
1059 assert_eq!(resp.status, "done");
1060 }
1061
1062 #[test]
1063 fn parse_normalizes_green_alias_to_done() {
1064 let input = r#"{"status":"green","summary":"all passing","accomplished":[],"remaining":[],"files":[]}"#;
1065 let resp = parse(input).unwrap();
1066 assert_eq!(resp.status, "done");
1067 }
1068
1069 #[test]
1070 fn parse_normalizes_alerts_sent_alias_to_done() {
1071 let input = r#"{"status":"alerts_sent","summary":"notifications dispatched","accomplished":[],"remaining":[],"files":[]}"#;
1072 let resp = parse(input).unwrap();
1073 assert_eq!(resp.status, "done");
1074 }
1075
1076 #[test]
1077 fn parse_normalizes_verified_alias_to_done() {
1078 let input = r#"{"status":"verified","summary":"result verified correct","accomplished":[],"remaining":[],"files":[]}"#;
1079 let resp = parse(input).unwrap();
1080 assert_eq!(resp.status, "done");
1081 }
1082
1083 #[test]
1084 fn parse_normalizes_fixed_alias_to_done() {
1085 let input = r#"{"status":"fixed","summary":"bug has been fixed","accomplished":["fixed bug"],"remaining":[],"files":["src/parser.rs"]}"#;
1086 let resp = parse(input).unwrap();
1087 assert_eq!(resp.status, "done");
1088 }
1089
1090 #[test]
1091 fn parse_normalizes_alerts_fired_alias_to_done() {
1092 let input = r#"{"status":"alerts_fired","summary":"alerts dispatched","accomplished":[],"remaining":[],"files":[]}"#;
1093 let resp = parse(input).unwrap();
1094 assert_eq!(resp.status, "done");
1095 }
1096
1097 #[test]
1098 fn parse_normalizes_waiting_alias_to_in_progress() {
1099 let input = r#"{"status":"waiting","summary":"blocked on upstream","accomplished":[],"remaining":[],"files":[]}"#;
1101 let resp = parse(input).unwrap();
1102 assert_eq!(resp.status, "in_progress");
1103 }
1104
1105 #[test]
1106 fn parse_normalizes_changes_pushed_alias_to_done() {
1107 let input = r#"{"status":"changes_pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1108 let resp = parse(input).unwrap();
1109 assert_eq!(resp.status, "done");
1110 }
1111
1112 #[test]
1113 fn parse_normalizes_bare_pushed_alias_to_done() {
1114 let input = r#"{"status":"pushed","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1115 let resp = parse(input).unwrap();
1116 assert_eq!(resp.status, "done");
1117 }
1118
1119 #[test]
1120 fn parse_normalizes_dynamic_pushed_to_branch_status_to_done() {
1121 let input = r#"{"status":"pushed to internal-155195-weekly-review-patterns-progress-and-next","summary":"changes pushed to branch","accomplished":[],"remaining":[],"files":[]}"#;
1122 let resp = parse(input).unwrap();
1123 assert_eq!(resp.status, "done");
1124 }
1125
1126 #[test]
1127 fn parse_normalizes_running_alias_to_in_progress() {
1128 let input = r#"{"status":"running","summary":"working","accomplished":[],"remaining":[],"files":[]}"#;
1129 let resp = parse(input).unwrap();
1130 assert_eq!(resp.status, "in_progress");
1131 }
1132
1133 #[test]
1134 fn parse_normalizes_reviewing_alias_to_in_review() {
1135 let input = r#"{"status":"reviewing","summary":"reviewing","accomplished":[],"remaining":[],"files":[]}"#;
1136 let resp = parse(input).unwrap();
1137 assert_eq!(resp.status, "in_review");
1138 }
1139
1140 #[test]
1141 fn parse_normalizes_pending_review_alias_to_needs_review() {
1142 let input = r#"{"status":"pending_review","summary":"ready","accomplished":[],"remaining":[],"files":[]}"#;
1143 let resp = parse(input).unwrap();
1144 assert_eq!(resp.status, "needs_review");
1145 }
1146
1147 #[test]
1148 fn parse_normalizes_error_alias_to_blocked() {
1149 let input =
1150 r#"{"status":"error","summary":"failed","accomplished":[],"remaining":[],"files":[]}"#;
1151 let resp = parse(input).unwrap();
1152 assert_eq!(resp.status, "blocked");
1153 }
1154
1155 #[test]
1156 fn parse_normalizes_failure_alias_to_blocked() {
1157 let input = r#"{"status":"failure","summary":"could not complete","accomplished":[],"remaining":[],"files":[]}"#;
1158 let resp = parse(input).unwrap();
1159 assert_eq!(resp.status, "blocked");
1160 }
1161
1162 #[test]
1163 fn parse_keeps_canonical_statuses_unchanged() {
1164 for status in &[
1165 "new",
1166 "routed",
1167 "in_progress",
1168 "done",
1169 "blocked",
1170 "in_review",
1171 "needs_review",
1172 ] {
1173 let input = format!(
1174 r#"{{"status":"{status}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
1175 );
1176 let resp = parse(&input).unwrap();
1177 assert_eq!(
1178 resp.status, *status,
1179 "canonical status '{status}' should be unchanged"
1180 );
1181 }
1182 }
1183
1184 #[test]
1185 fn parse_keeps_non_canonical_status_as_is_for_audit_trail() {
1186 let input = r#"{"status":"fix_deployed","summary":"deployed","accomplished":[],"remaining":[],"files":[]}"#;
1189 let resp = parse(input).unwrap();
1190 assert_eq!(resp.status, "fix_deployed");
1192 }
1193
1194 #[test]
1195 fn parse_normalizes_heuristic_completion_alias_to_done() {
1196 for status in &["addressed", "duplicate_skipped"] {
1199 let input = format!(
1200 r#"{{"status":"{status}","summary":"done","accomplished":[],"remaining":[],"files":[]}}"#
1201 );
1202 let resp = parse(&input).unwrap();
1203 assert_eq!(
1204 resp.status, "done",
1205 "status '{status}' should normalize to done"
1206 );
1207 }
1208 }
1209
1210 #[test]
1211 fn parse_fails_when_summary_present_but_status_missing() {
1212 let input = r#"{"summary":"still working","accomplished":[],"remaining":[],"files":[]}"#;
1215 let result = parse(input);
1216 assert!(
1217 result.is_err(),
1218 "missing status should error, not default to done"
1219 );
1220 }
1221
1222 #[test]
1223 fn parse_fails_when_files_present_but_status_missing() {
1224 let input = r#"{"summary":"partial output","files":["src/foo.rs"]}"#;
1226 let result = parse(input);
1227 assert!(
1228 result.is_err(),
1229 "missing status should error, not default to done"
1230 );
1231 }
1232
1233 #[test]
1234 fn parse_fails_when_only_message_present_without_status() {
1235 let input = r#"{"message":"working on it"}"#;
1237 let result = parse(input);
1238 assert!(
1239 result.is_err(),
1240 "missing status should error, not default to done"
1241 );
1242 }
1243
1244 #[test]
1247 fn parse_regression_2880_ready_for_review_normalized() {
1248 let input = r#"{"status":"ready_for_review","summary":"Ready for review","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
1250 let resp = parse(input).unwrap();
1251 assert_eq!(resp.status, "needs_review");
1252 }
1253
1254 #[test]
1255 fn parse_regression_2880_status_with_output_accepted() {
1256 let input = r#"{"status":"done","output":"All tests passed"}"#;
1259 let resp = parse(input).unwrap();
1260 assert_eq!(resp.status, "done");
1261 }
1262
1263 #[test]
1264 fn parse_regression_2880_mixed_ndjson_chooses_best_candidate() {
1265 let input = r#"{"status":"src/channels/discord_ws.rs","summary":"","accomplished":[],"remaining":[],"files":[]}
1270{"status":"done","summary":"Fixed websocket timeout","accomplished":["fixed"],"remaining":[],"files":["src/channels/discord_ws.rs"]}"#;
1271 let resp = parse(input).unwrap();
1272 assert_eq!(resp.status, "done");
1274 assert_eq!(resp.summary, "Fixed websocket timeout");
1275 }
1276
1277 #[test]
1278 fn parse_regression_2880_normalize_aliases() {
1279 for (input, expected) in [
1281 ("ok", "done"),
1282 ("success", "done"),
1283 ("completed", "done"),
1284 ("complete", "done"),
1285 ("skipped", "done"),
1286 ("skip", "done"),
1287 ("no_changes_needed", "done"),
1288 ("no_trades_no_positions", "done"),
1289 ("changes_made", "done"),
1290 ("changes_pushed", "done"),
1291 ("changes_addressed", "done"),
1292 ("changes_requested_addressed", "done"),
1293 ("fixed", "done"),
1294 ("running", "in_progress"),
1295 ("partial", "in_progress"),
1296 ("reviewing", "in_review"),
1297 ("pending_review", "needs_review"),
1298 ("ready_for_review", "needs_review"),
1299 ("error", "blocked"),
1300 ("failed", "blocked"),
1301 ] {
1302 let input = format!(
1303 r#"{{"status":"{input}","summary":"test","accomplished":[],"remaining":[],"files":[]}}"#
1304 );
1305 let resp = parse(&input).unwrap();
1306 assert_eq!(
1307 resp.status, expected,
1308 "status '{input}' should normalize to '{expected}'"
1309 );
1310 }
1311 }
1312
1313 #[test]
1314 fn parse_regression_2880_telemetry_skipped() {
1315 let input = r#"{"type":"telemetry","event":"tool_call"}
1317{"status":"done","summary":"Work complete","accomplished":["done"],"remaining":[],"files":["src/main.rs"]}"#;
1318 let resp = parse(input).unwrap();
1319 assert_eq!(resp.status, "done");
1320 assert_eq!(resp.summary, "Work complete");
1321 }
1322
1323 #[test]
1326 fn parse_regression_2979_success_true_envelope() {
1327 let input = r#"The fix removes all actionable entry/SL/TP/R:R parameters from the bearish-mode trade section. The three candidates are now a plain watchlist table.
1330
1331```json
1332{"success": true, "summary": "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."}
1333```"#;
1334 let resp = parse(input).unwrap();
1335 assert_eq!(resp.status, "done");
1336 assert_eq!(
1337 resp.summary,
1338 "Fixed bearish/longs contradiction: replaced actionable long setups with a non-executable watchlist table."
1339 );
1340 }
1341
1342 #[test]
1343 fn parse_regression_2979_type_task_complete_envelope() {
1344 let input = r#"The daily trending doc is complete at md/research/2026-04-22-daily-trending.md.
1347
1348```json
1349{
1350 "type": "task_complete",
1351 "summary": "Fetched global trending topics and saved to md/research/2026-04-22-daily-trending.md.",
1352 "files_changed": ["md/research/2026-04-22-daily-trending.md"]
1353}
1354```"#;
1355 let resp = parse(input).unwrap();
1356 assert_eq!(resp.status, "done");
1357 assert!(resp.summary.contains("Fetched global trending topics"));
1358 assert_eq!(resp.files, vec!["md/research/2026-04-22-daily-trending.md"]);
1359 }
1360
1361 #[test]
1362 fn parse_regression_2979_tests_passed_envelope() {
1363 let input = r#"PR created: gabrielkoerich/orch#2969
1366
1367The fix from attempt #8 was already committed. All quality gates pass.
1368
1369```json
1370{
1371 "summary": "Retry watermark persistence on store failure.",
1372 "files_modified": ["src/engine/review_poll.rs"],
1373 "pr_url": "https://github.com/gabrielkoerich/orch/pull/2969",
1374 "tests_passed": true,
1375 "quality_gates": ["cargo fmt", "cargo clippy", "cargo nextest run"]
1376}
1377```"#;
1378 let resp = parse(input).unwrap();
1379 assert_eq!(resp.status, "done");
1380 assert!(resp.summary.contains("Retry watermark persistence"));
1381 assert_eq!(resp.files, vec!["src/engine/review_poll.rs"]);
1382 }
1383
1384 #[test]
1385 fn parse_regression_2979_success_false_does_not_infer_done() {
1386 let input = r#"```json
1388{"success": false, "summary": "Could not complete the task."}
1389```"#;
1390 let result = parse(input);
1391 assert!(
1394 result.is_err(),
1395 "success: false should not be treated as done"
1396 );
1397 }
1398
1399 #[test]
1400 fn parse_regression_2979_type_other_does_not_infer_done() {
1401 let input = r#"{"type": "tool_call", "summary": "calling bash"}"#;
1403 let result = parse(input);
1404 assert!(
1405 result.is_err(),
1406 "type other than task_complete should not be treated as done"
1407 );
1408 }
1409
1410 #[test]
1411 fn parse_regression_2979_files_modified_alias() {
1412 let input = r#"{"status": "done", "summary": "done", "files_modified": ["src/foo.rs", "src/bar.rs"]}"#;
1414 let resp = parse(input).unwrap();
1415 assert_eq!(resp.files, vec!["src/foo.rs", "src/bar.rs"]);
1416 }
1417
1418 #[test]
1421 fn parse_regression_3336_jsonl_domain_status_field_rejected() {
1422 let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
1426{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}"#;
1427 let err = parse(input).unwrap_err();
1428 let msg = err.to_string();
1429 assert!(
1430 msg.contains("agent output is not valid JSON or a supported JSON wrapper")
1431 || msg.contains("JSON object lacks substantive"),
1432 "unexpected error: {msg}"
1433 );
1434 }
1435
1436 #[test]
1437 fn parse_regression_3336_single_domain_record_rejected() {
1438 let input = r#"{"status":"inbound-not-submitted","date":"2026-06-15","role":"Engineer","company":"Acme"}"#;
1440 let err = parse(input).unwrap_err();
1441 let msg = err.to_string();
1442 assert!(
1443 msg.contains("agent output is not valid JSON or a supported JSON wrapper")
1444 || msg.contains("JSON object lacks substantive"),
1445 "unexpected error: {msg}"
1446 );
1447 }
1448
1449 #[test]
1450 fn parse_regression_3336_noncanonical_status_with_summary_still_accepted() {
1451 let input = r#"{"status":"fix_deployed","summary":"deployed to staging","accomplished":[],"remaining":[],"files":[]}"#;
1454 let resp = parse(input).unwrap();
1455 assert_eq!(resp.status, "fix_deployed");
1456 assert_eq!(resp.summary, "deployed to staging");
1457 }
1458
1459 #[test]
1460 fn parse_regression_3336_jsonl_domain_plus_orch_response_prefers_orch() {
1461 let input = r#"{"date":"2026-06-15","role":"Senior Solana Engineer","company":"M0","status":"inbound-not-submitted","source":"referral"}
1464{"date":"2026-06-15","role":"Rust Engineer (Solana)","company":"Exo Tech","status":"applied-follow-up-overdue","source":"linkedin"}
1465{"status":"done","summary":"Career radar written to md/career/2026-06-15-radar.md","accomplished":["Wrote career radar"],"remaining":[],"files":["md/career/2026-06-15-radar.md"]}"#;
1466 let resp = parse(input).unwrap();
1467 assert_eq!(resp.status, "done");
1468 assert_eq!(
1469 resp.summary,
1470 "Career radar written to md/career/2026-06-15-radar.md"
1471 );
1472 assert_eq!(resp.files, vec!["md/career/2026-06-15-radar.md"]);
1473 }
1474
1475 #[test]
1476 fn parse_regression_3336_noncanonical_with_output_accepted() {
1477 let input = r#"{"status":"mystery_status","output":"All tests passed"}"#;
1482 let resp = parse(input).unwrap();
1483 assert_eq!(resp.status, "mystery_status");
1484 }
1485
1486 #[test]
1487 fn parse_regression_3336_noncanonical_with_message_accepted() {
1488 let input = r#"{"status":"mystery_status","message":"All tests passed"}"#;
1491 let resp = parse(input).unwrap();
1492 assert_eq!(resp.status, "mystery_status");
1493 }
1494}