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