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