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