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