1use serde::{Deserialize, Serialize};
135use similar::{ChangeTag, TextDiff};
136
137use crate::component;
138
139const IMPERATIVE_LEADING_VERBS: &[&str] = &[
140 "add",
141 "audit",
142 "benchmark",
143 "build",
144 "check",
145 "clean",
146 "close",
147 "commit",
148 "document",
149 "fix",
150 "harden",
151 "implement",
152 "install",
153 "investigate",
154 "note",
155 "preserve",
156 "push",
157 "record",
158 "refactor",
159 "remove",
160 "repair",
161 "rerun",
162 "revisit",
163 "run",
164 "test",
165 "trace",
166 "update",
167 "verify",
168 "write",
169];
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum DiffType {
175 Approval,
177 SimpleQuestion,
179 BoundaryArtifact,
181 Annotation,
183 StructuralChange,
185 MultiTopic,
187 ContentAddition,
189}
190
191#[derive(Debug, Clone, Serialize)]
193pub struct DiffClassification {
194 pub diff_type: DiffType,
195 pub diff_type_reason: String,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum PromptBearingChangeKind {
202 PromptTarget,
203 ContentEdit,
204 RecoveryArtifact,
205 BoundaryArtifact,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct PromptBearingChange {
211 pub kind: PromptBearingChangeKind,
212 pub text: String,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216struct PromptPrefixLine {
217 raw: String,
218 prefixed: bool,
219}
220
221pub fn strip_comments(content: &str) -> String {
226 crate::frontmatter::strip_pipeline_block_lines(&component::strip_comments(content))
233}
234
235pub fn annotate_diff(diff_text: &str) -> Option<String> {
246 let mut result = Vec::new();
247 let mut pending_removes: Vec<String> = Vec::new();
248
249 for line in diff_text.lines() {
250 if line.starts_with("--- ") || line.starts_with("+++ ") {
252 continue;
253 }
254 if line.starts_with("@@ ") {
255 flush_removes(&mut pending_removes, &mut result);
257 continue;
258 }
259
260 if let Some(content) = line.strip_prefix('+') {
261 if let Some(removed) = pending_removes.pop() {
263 let common = common_prefix_len(content, &removed);
264 let min_len = content.len().min(removed.len());
265 if min_len >= 3 && common > min_len * 6 / 10 {
266 result.push(format!("[user~] {}", content));
268 } else {
269 result.push(format!("[user-] {}", removed));
271 result.push(format!("[user+] {}", content));
272 }
273 } else {
274 result.push(format!("[user+] {}", content));
275 }
276 } else if let Some(content) = line.strip_prefix('-') {
277 pending_removes.push(content.to_string());
279 } else if let Some(content) = line.strip_prefix(' ') {
280 flush_removes(&mut pending_removes, &mut result);
281 result.push(format!("[agent] {}", content));
282 } else if !line.is_empty() {
283 flush_removes(&mut pending_removes, &mut result);
284 result.push(format!("[agent] {}", line));
285 }
286 }
287
288 flush_removes(&mut pending_removes, &mut result);
289
290 if result.is_empty() {
291 None
292 } else {
293 Some(result.join("\n"))
294 }
295}
296
297pub fn extract_inline_annotations(annotated_diff: &str) -> Vec<String> {
307 classify_prompt_bearing_changes_from_annotated_internal(annotated_diff, false)
308 .into_iter()
309 .filter_map(|change| match change.kind {
310 PromptBearingChangeKind::PromptTarget | PromptBearingChangeKind::ContentEdit => {
311 Some(change.text)
312 }
313 PromptBearingChangeKind::RecoveryArtifact
314 | PromptBearingChangeKind::BoundaryArtifact => None,
315 })
316 .collect()
317}
318
319fn is_substantive_agent_line(line: &str) -> bool {
323 let Some(content) = line.strip_prefix("[agent] ") else {
324 return false;
325 };
326 let t = content.trim_start();
327 !t.is_empty() && !t.starts_with("<!--") && !t.starts_with('#')
328}
329
330fn is_head_boundary_artifact(content: &str) -> bool {
333 content.ends_with(" (HEAD)") && {
334 let base = &content[..content.len() - " (HEAD)".len()];
335 base.trim_start_matches('#').starts_with(' ')
336 || base.contains("### Re:")
337 || base.contains("## ")
338 }
339}
340
341fn is_boundary_artifact_line(content: &str) -> bool {
342 let trimmed = content.trim();
343 trimmed.starts_with("<!-- agent:boundary:")
344 || is_head_boundary_artifact(trimmed)
345 || trimmed == "(HEAD)"
346}
347
348fn is_recovery_artifact_line(content: &str) -> bool {
349 let trimmed = content.trim_start();
350 trimmed.starts_with("### Re:")
351 || trimmed.starts_with("#### Re:")
352 || trimmed.starts_with("##### Re:")
353 || trimmed == "## Assistant"
354 || trimmed == "## User"
355 || trimmed.starts_with("<!-- patch:")
356 || trimmed.starts_with("<!-- /patch:")
357 || line_is_binary_authored_ipc_proof_diagnostic(trimmed)
358 || line_is_binary_authored_compact_summary(trimmed)
359}
360
361pub fn line_is_binary_authored_ipc_proof_diagnostic(line: &str) -> bool {
379 let trimmed = line.trim();
380 if trimmed.starts_with("ipc_proof_insufficient")
385 && trimmed.contains("invariant=")
386 && trimmed.contains("recovery=")
387 {
388 return true;
389 }
390 if trimmed
392 == "This is binary-authored diagnostic content, not a user prompt, so it does not require a separate response cycle."
393 {
394 return true;
395 }
396 false
397}
398
399pub fn line_is_binary_authored_compact_summary(line: &str) -> bool {
420 let trimmed = line.trim();
421 let trimmed = trimmed
425 .strip_prefix("❯ ")
426 .or_else(|| trimmed.strip_prefix('❯'))
427 .map(str::trim_start)
428 .unwrap_or(trimmed);
429 trimmed == "### Session Summary"
430 || trimmed == "Compacted content:"
431 || trimmed.starts_with("*Compacted. Content archived to `")
432 || (trimmed.starts_with("- Archived ") && trimmed.contains("response topic(s):"))
433 || trimmed.starts_with("- Prior summary/context:")
434 || trimmed.starts_with("- Trailing prompt/context:")
435}
436
437fn is_exchange_close_marker_line(content: &str) -> bool {
438 content.trim() == "<!-- /agent:exchange -->"
439}
440
441fn parse_slash_command_candidate(line: &str) -> Option<String> {
442 let trimmed = line.trim();
443 let rest = trimmed.strip_prefix('/')?;
444 let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
445 let token = &rest[..token_end];
446 if token.is_empty() {
447 return None;
448 }
449 let mut chars = token.chars();
450 let first = chars.next()?;
451 let command_like = first.is_ascii_lowercase()
452 && chars
453 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, ':' | '_' | '-'));
454 command_like.then(|| trimmed.to_string())
455}
456
457fn line_looks_like_slash_command(line: &str) -> bool {
458 parse_slash_command_candidate(line).is_some()
459}
460
461fn line_looks_like_prompt_target(line: &str) -> bool {
462 let trimmed = line.trim();
463 let normalized_imperative = normalize_imperative_candidate(trimmed)
464 .is_some_and(|normalized| looks_like_imperative_directive(&normalized));
465 let slash_command = line_looks_like_slash_command(trimmed);
466 let prompt_prefixed = trimmed.starts_with('❯') && !line_looks_like_markdown_list_item(trimmed);
467 !trimmed.is_empty()
468 && !trimmed.starts_with("<!--")
469 && !trimmed.starts_with("```")
470 && !trimmed.starts_with("~~~")
471 && !trimmed.starts_with("### Re:")
472 && !line_has_known_response_label_after_prompt(trimmed)
473 && (slash_command || prompt_prefixed || trimmed.ends_with('?') || normalized_imperative)
474}
475
476pub fn text_line_looks_like_prompt_target(line: &str) -> bool {
477 line_looks_like_prompt_target(line)
478}
479
480fn block_looks_like_prompt_target(block: &str) -> bool {
481 block.lines().any(line_looks_like_prompt_target)
482}
483
484fn is_exchange_response_heading(trimmed: &str) -> bool {
485 trimmed == "## Assistant"
486 || trimmed.starts_with("### Re:")
487 || trimmed.starts_with("#### Re:")
488 || trimmed.starts_with("##### Re:")
489 || trimmed.starts_with("###### Re:")
490}
491
492fn normalized_prompt_preview_line(line: &str) -> Option<String> {
493 let trimmed = line.trim();
494 if trimmed.is_empty() || !text_line_looks_like_prompt_target(trimmed) {
495 return None;
496 }
497 Some(trimmed.trim_start_matches('❯').trim().to_string())
498}
499
500pub fn line_looks_like_fresh_prompt_after_response(trimmed: &str) -> bool {
501 if line_looks_like_plain_response_after_prompt(trimmed) {
502 return false;
503 }
504
505 let unprefixed = trimmed.trim_start_matches('❯').trim();
506 let lower = unprefixed.to_ascii_lowercase();
507 trimmed.starts_with('❯')
508 || unprefixed.ends_with('?')
509 || line_looks_like_slash_command(unprefixed)
510 || lower == "go"
511 || lower == "continue"
512 || lower.starts_with("do #")
513 || lower.starts_with("do [#")
514 || lower.starts_with("fix #")
515 || lower.starts_with("run ")
516 || lower.starts_with("rerun ")
517 || lower.starts_with("build ")
518 || lower.starts_with("test ")
519 || lower.starts_with("commit ")
520 || lower.starts_with("push ")
521 || lower.starts_with("verify ")
522 || lower.starts_with("investigate ")
523}
524
525pub(crate) fn line_looks_like_soft_prompt_request(trimmed: &str) -> bool {
526 let lower = trimmed.to_ascii_lowercase();
527 lower.starts_with("please ")
528 || lower.contains(" please ")
529 || lower.starts_with("can you ")
530 || lower.starts_with("could you ")
531 || lower.starts_with("would you ")
532 || lower.starts_with("need you to ")
533}
534
535pub fn line_looks_like_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
536 let unprefixed = trimmed
537 .strip_prefix("❯ ")
538 .or_else(|| trimmed.strip_prefix('❯'))
539 .map(str::trim_start)
540 .unwrap_or(trimmed);
541
542 if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
543 return false;
544 }
545
546 is_target
547 || line_looks_like_fresh_prompt_after_response(unprefixed)
548 || line_looks_like_soft_prompt_request(unprefixed)
549}
550
551pub fn line_looks_like_targeted_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
552 if !is_target {
553 return false;
554 }
555
556 let unprefixed = trimmed
557 .strip_prefix("❯ ")
558 .or_else(|| trimmed.strip_prefix('❯'))
559 .map(str::trim_start)
560 .unwrap_or(trimmed);
561
562 if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
563 return false;
564 }
565
566 if (trimmed.starts_with('❯') && !line_looks_like_markdown_list_item(trimmed))
567 || line_looks_like_soft_prompt_request(unprefixed)
568 {
569 return true;
570 }
571
572 let lower = unprefixed.to_ascii_lowercase();
573 lower == "go"
574 || lower == "continue"
575 || line_looks_like_slash_command(unprefixed)
576 || lower.starts_with("do #")
577 || lower.starts_with("do [#")
578 || lower.starts_with("fix #")
579 || lower.starts_with("run ")
580 || lower.starts_with("rerun ")
581 || lower.starts_with("build ")
582 || lower.starts_with("test ")
583 || lower.starts_with("commit ")
584 || lower.starts_with("push ")
585 || lower.starts_with("verify ")
586 || lower.starts_with("investigate ")
587}
588
589pub fn line_looks_like_plain_response_after_prompt(trimmed: &str) -> bool {
590 if trimmed.is_empty() {
591 return false;
592 }
593
594 if line_has_known_response_label_after_prompt(trimmed) {
595 return true;
596 }
597
598 if normalized_prompt_preview_line(trimmed).is_some() {
599 return false;
600 }
601
602 if line_looks_like_markdown_list_item(trimmed)
603 || trimmed.starts_with("- ")
604 || trimmed.starts_with("* ")
605 || trimmed.starts_with("Plan:")
606 || trimmed.starts_with("Verification")
607 || trimmed.starts_with("What changed:")
608 || trimmed.starts_with("Follow-up:")
609 || trimmed.starts_with("Backlog:")
610 || trimmed.starts_with("`#")
611 {
612 return true;
613 }
614
615 let lower = trimmed.to_ascii_lowercase();
616 lower.starts_with("i updated ")
617 || lower.starts_with("i fixed ")
618 || lower.starts_with("i added ")
619 || lower.starts_with("i implemented ")
620 || lower.starts_with("i left ")
621 || lower.starts_with("i dry-ran ")
622 || lower.starts_with("recovered ")
623 || lower.starts_with("confirmed ")
624 || lower.starts_with("no code files changed")
625 || lower.starts_with("this closeout ")
626 || lower.starts_with("updated ")
627 || lower.starts_with("fixed ")
628 || lower.starts_with("added ")
629 || lower.starts_with("implemented ")
630}
631
632fn line_has_known_response_label_after_prompt(line: &str) -> bool {
633 let Some(normalized) = normalize_response_label_candidate(line) else {
634 return false;
635 };
636 matches!(
637 normalized.as_str(),
638 s if s.starts_with("Plan:")
639 || s.starts_with("Verification:")
640 || s.starts_with("What changed:")
641 || s.starts_with("Follow-up:")
642 || s.starts_with("Commit / push:")
643 || s.starts_with("Backlog:")
644 )
645}
646
647fn normalize_response_label_candidate(line: &str) -> Option<String> {
648 let mut trimmed = line.trim();
649 if trimmed.is_empty() {
650 return None;
651 }
652
653 if let Some(rest) = trimmed.strip_prefix('❯') {
654 trimmed = rest.trim_start();
655 }
656
657 if let Some(rest) = trimmed
658 .strip_prefix("- ")
659 .or_else(|| trimmed.strip_prefix("* "))
660 .or_else(|| trimmed.strip_prefix("+ "))
661 {
662 trimmed = rest.trim_start();
663 } else {
664 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
665 if digits > 0 {
666 let rest = &trimmed[digits..];
667 if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
668 trimmed = rest.trim_start();
669 }
670 }
671 }
672
673 let stripped = strip_markdown_emphasis_pair(trimmed);
674 let normalized = stripped.trim_start();
675 (!normalized.is_empty()).then(|| normalized.to_string())
676}
677
678fn strip_markdown_emphasis_pair(text: &str) -> String {
679 for marker in ["***", "___", "**", "__", "*", "_"] {
680 if let Some(rest) = text.strip_prefix(marker)
681 && let Some(end) = rest.find(marker)
682 {
683 let label = &rest[..end];
684 let tail = &rest[end + marker.len()..];
685 if !label.trim().is_empty()
686 && (label.trim_end().ends_with(':') || tail.trim_start().starts_with(':'))
687 {
688 return format!("{}{}", label, tail);
689 }
690 }
691 }
692 text.to_string()
693}
694
695pub fn prompt_change_is_already_answered(change_text: &str) -> bool {
696 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
697 let fc = trimmed.chars().next()?;
698 if fc != '`' && fc != '~' {
699 return None;
700 }
701 let fl = trimmed.chars().take_while(|&c| c == fc).count();
702 if fl >= 3 { Some((fc, fl)) } else { None }
703 }
704
705 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
706 let fc = trimmed.chars().next().unwrap_or('\0');
707 if fc != fence_char {
708 return false;
709 }
710 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
711 fl >= fence_len && trimmed[fl..].trim().is_empty()
712 }
713
714 let mut in_fence = false;
715 let mut fence_char = '`';
716 let mut fence_len = 0usize;
717 let mut saw_prompt = false;
718 let mut saw_response = false;
719
720 for segment in change_text.split_inclusive('\n') {
721 let line = segment.trim_end_matches('\n');
722 let trimmed = line.trim();
723 if trimmed.is_empty() || trimmed.starts_with("<!--") {
724 continue;
725 }
726
727 if !in_fence {
728 if let Some((fc, fl)) = fence_open(trimmed) {
729 in_fence = true;
730 fence_char = fc;
731 fence_len = fl;
732 continue;
733 }
734 } else {
735 if fence_close(trimmed, fence_char, fence_len) {
736 in_fence = false;
737 }
738 continue;
739 }
740
741 if is_exchange_response_heading(trimmed) {
742 if saw_prompt {
743 saw_response = true;
744 }
745 continue;
746 }
747
748 if normalized_prompt_preview_line(trimmed).is_some() {
749 if saw_response && line_looks_like_fresh_prompt_after_response(trimmed) {
750 return false;
751 }
752 saw_prompt = true;
753 continue;
754 }
755
756 if !saw_prompt && line_looks_like_soft_prompt_request(trimmed) {
757 saw_prompt = true;
758 continue;
759 }
760
761 if saw_prompt && line_looks_like_plain_response_after_prompt(trimmed) {
762 saw_response = true;
763 }
764 }
765
766 saw_prompt && saw_response
767}
768
769pub fn prompt_change_is_answered_by_later_response(
770 changes: &[PromptBearingChange],
771 idx: usize,
772) -> bool {
773 if changes
774 .get(idx)
775 .is_none_or(|change| change.kind != PromptBearingChangeKind::PromptTarget)
776 {
777 return false;
778 }
779
780 for later in changes.iter().skip(idx + 1) {
781 match later.kind {
782 PromptBearingChangeKind::PromptTarget => return false,
783 PromptBearingChangeKind::RecoveryArtifact
784 | PromptBearingChangeKind::BoundaryArtifact => {
785 let heading = later
786 .text
787 .lines()
788 .find(|line| !line.trim().is_empty())
789 .unwrap_or(later.text.as_str())
790 .trim();
791 if is_exchange_response_heading(heading) {
792 return true;
793 }
794 }
795 PromptBearingChangeKind::ContentEdit => {}
796 }
797 }
798
799 false
800}
801
802fn suppress_answered_prompt_runs(changes: Vec<PromptBearingChange>) -> Vec<PromptBearingChange> {
803 let mut filtered = Vec::with_capacity(changes.len());
804 let mut skip_answered_response_run = false;
805
806 for (idx, change) in changes.iter().enumerate() {
807 match change.kind {
808 PromptBearingChangeKind::RecoveryArtifact
809 | PromptBearingChangeKind::BoundaryArtifact => {
810 filtered.push(change.clone());
811 }
812 PromptBearingChangeKind::PromptTarget => {
813 if skip_answered_response_run {
814 let preview = change
815 .text
816 .lines()
817 .find(|line| !line.trim().is_empty())
818 .unwrap_or(change.text.as_str())
819 .trim();
820 if !line_looks_like_fresh_prompt_after_response(preview) {
821 continue;
822 }
823 }
824 if prompt_change_is_already_answered(&change.text)
825 || prompt_change_is_answered_by_later_response(&changes, idx)
826 {
827 skip_answered_response_run = true;
828 continue;
829 }
830 filtered.push(change.clone());
831 }
832 PromptBearingChangeKind::ContentEdit => {
833 if skip_answered_response_run {
834 continue;
835 }
836 filtered.push(change.clone());
837 }
838 }
839 }
840
841 filtered
842}
843
844fn classify_prompt_bearing_block(
845 block_text: &str,
846 has_substantive_agent_after: bool,
847 closes_exchange_tail: bool,
848) -> Option<PromptBearingChangeKind> {
849 let trimmed = block_text.trim();
850 if trimmed.is_empty() {
851 return None;
852 }
853 let non_blank: Vec<&str> = trimmed
854 .lines()
855 .map(str::trim)
856 .filter(|line| !line.is_empty())
857 .collect();
858 if non_blank.is_empty() {
859 return None;
860 }
861 if non_blank.iter().all(|line| is_boundary_artifact_line(line)) {
862 return Some(PromptBearingChangeKind::BoundaryArtifact);
863 }
864 if non_blank
865 .first()
866 .is_some_and(|line| is_recovery_artifact_line(line))
867 {
868 return Some(PromptBearingChangeKind::RecoveryArtifact);
869 }
870 if non_blank
877 .iter()
878 .any(|line| line_is_binary_authored_ipc_proof_diagnostic(line))
879 {
880 return Some(PromptBearingChangeKind::RecoveryArtifact);
881 }
882 if non_blank
889 .iter()
890 .all(|line| line_is_binary_authored_compact_summary(line))
891 {
892 return Some(PromptBearingChangeKind::RecoveryArtifact);
893 }
894 if block_looks_like_prompt_target(trimmed) {
895 return Some(PromptBearingChangeKind::PromptTarget);
896 }
897 if closes_exchange_tail
898 && non_blank
899 .iter()
900 .all(|line| line_looks_like_plain_response_after_prompt(line))
901 {
902 return None;
903 }
904 if closes_exchange_tail {
905 return Some(PromptBearingChangeKind::PromptTarget);
906 }
907 if has_substantive_agent_after {
908 return Some(PromptBearingChangeKind::ContentEdit);
909 }
910 None
911}
912
913pub fn prompt_prefix_normalization_targets(diff: &str) -> Vec<String> {
919 let mut seen = std::collections::HashSet::<String>::new();
920 let mut lines = Vec::new();
921 for change in classify_prompt_bearing_changes_raw(diff) {
922 if change.kind != PromptBearingChangeKind::PromptTarget {
923 continue;
924 }
925 for line in prompt_prefix_lines_from_block(&change.text) {
926 if line.prefixed {
927 continue;
928 }
929 if seen.insert(line.raw.clone()) {
930 lines.push(line.raw);
931 }
932 }
933 }
934 lines
935}
936
937pub fn first_bare_prompt_prefix_target(diff: &str) -> Option<String> {
939 for change in classify_prompt_bearing_changes_raw(diff) {
940 if change.kind != PromptBearingChangeKind::PromptTarget {
941 continue;
942 }
943 for line in prompt_prefix_lines_from_block(&change.text) {
944 if !line.prefixed {
945 return Some(line.raw);
946 }
947 }
948 }
949 None
950}
951
952fn classify_prompt_bearing_changes_from_annotated(
953 annotated_diff: &str,
954) -> Vec<PromptBearingChange> {
955 classify_prompt_bearing_changes_from_annotated_internal(annotated_diff, true)
956}
957
958fn classify_prompt_bearing_changes_from_annotated_internal(
959 annotated_diff: &str,
960 promote_exchange_tail_prompts: bool,
961) -> Vec<PromptBearingChange> {
962 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
963 let fc = trimmed.chars().next()?;
964 if fc != '`' && fc != '~' {
965 return None;
966 }
967 let fl = trimmed.chars().take_while(|&c| c == fc).count();
968 if fl >= 3 { Some((fc, fl)) } else { None }
969 }
970
971 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
972 let fc = trimmed.chars().next().unwrap_or('\0');
973 if fc != fence_char {
974 return false;
975 }
976 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
977 fl >= fence_len && trimmed[fl..].trim().is_empty()
978 }
979
980 let lines: Vec<&str> = annotated_diff.lines().collect();
981 let mut changes = Vec::new();
982 let mut i = 0usize;
983
984 while i < lines.len() {
985 let Some(mut block) = lines[i]
986 .strip_prefix("[user+] ")
987 .or_else(|| lines[i].strip_prefix("[user~] "))
988 .map(|line| vec![line.to_string()])
989 else {
990 i += 1;
991 continue;
992 };
993
994 let mut in_fence = false;
995 let mut fence_char = '`';
996 let mut fence_len = 3usize;
997 let first_trimmed = block[0].trim();
998 if let Some((fc, fl)) = fence_open(first_trimmed) {
999 in_fence = true;
1000 fence_char = fc;
1001 fence_len = fl;
1002 }
1003
1004 i += 1;
1005 while i < lines.len() {
1006 if let Some(content) = lines[i]
1007 .strip_prefix("[user+] ")
1008 .or_else(|| lines[i].strip_prefix("[user~] "))
1009 {
1010 let trimmed = content.trim();
1011 let starts_new_block = !in_fence
1012 && block.last().is_some_and(|line| line.trim().is_empty())
1013 && !trimmed.is_empty()
1014 && !trimmed.starts_with("```")
1015 && !trimmed.starts_with("~~~");
1016 if starts_new_block {
1017 break;
1018 }
1019 block.push(content.to_string());
1020 if !in_fence {
1021 if let Some((fc, fl)) = fence_open(trimmed) {
1022 in_fence = true;
1023 fence_char = fc;
1024 fence_len = fl;
1025 }
1026 } else if fence_close(trimmed, fence_char, fence_len) {
1027 in_fence = false;
1028 }
1029 i += 1;
1030 } else {
1031 break;
1032 }
1033 }
1034
1035 while block.first().is_some_and(|line| line.trim().is_empty()) {
1036 block.remove(0);
1037 }
1038 while block.last().is_some_and(|line| line.trim().is_empty()) {
1039 block.pop();
1040 }
1041 if block.is_empty() {
1042 continue;
1043 }
1044
1045 let has_substantive_agent_after = lines[i..]
1046 .iter()
1047 .any(|line| is_substantive_agent_line(line));
1048 let closes_exchange_tail = lines[i..]
1049 .iter()
1050 .find_map(|line| {
1051 let trimmed = line.trim();
1052 if trimmed.is_empty() {
1053 return None;
1054 }
1055 line.strip_prefix("[agent] ")
1056 })
1057 .is_some_and(is_exchange_close_marker_line);
1058 let text = block.join("\n");
1059 let Some(kind) = classify_prompt_bearing_block(
1060 &text,
1061 has_substantive_agent_after,
1062 promote_exchange_tail_prompts && closes_exchange_tail,
1063 ) else {
1064 continue;
1065 };
1066 changes.push(PromptBearingChange { kind, text });
1067 }
1068
1069 changes
1070}
1071
1072fn classify_prompt_bearing_changes_raw(diff: &str) -> Vec<PromptBearingChange> {
1073 let mut changes = annotate_diff(diff)
1074 .map(|annotated| classify_prompt_bearing_changes_from_annotated(&annotated))
1075 .unwrap_or_default();
1076
1077 for text in extract_prompt_target_blocks(diff) {
1082 if changes
1083 .iter()
1084 .any(|existing| prompt_target_block_already_classified(existing, &text))
1085 {
1086 continue;
1087 }
1088 changes.push(PromptBearingChange {
1089 kind: PromptBearingChangeKind::PromptTarget,
1090 text,
1091 });
1092 }
1093
1094 changes
1095}
1096
1097fn prompt_target_block_already_classified(
1098 existing: &PromptBearingChange,
1099 prompt_text: &str,
1100) -> bool {
1101 if existing.text == prompt_text {
1102 return true;
1103 }
1104 match existing.kind {
1105 PromptBearingChangeKind::PromptTarget => {
1106 (existing.text.contains(prompt_text) || prompt_text.contains(&existing.text))
1107 && prompt_change_is_already_answered(&existing.text)
1108 }
1109 PromptBearingChangeKind::RecoveryArtifact
1110 | PromptBearingChangeKind::BoundaryArtifact
1111 | PromptBearingChangeKind::ContentEdit => existing.text.contains(prompt_text),
1112 }
1113}
1114
1115pub fn classify_prompt_bearing_changes(diff: &str) -> Vec<PromptBearingChange> {
1116 suppress_answered_prompt_runs(classify_prompt_bearing_changes_raw(diff))
1117}
1118
1119pub fn change_is_managed_state_only(change: &PromptBearingChange) -> bool {
1125 text_is_managed_state_only(&change.text)
1126}
1127
1128fn text_is_managed_state_only(text: &str) -> bool {
1129 let trimmed = text.trim();
1130 if trimmed.is_empty() {
1131 return false;
1132 }
1133 let lines: Vec<&str> = trimmed.lines().collect();
1134 if lines.is_empty() {
1135 return false;
1136 }
1137 lines.iter().all(|line| line_is_managed_state_only(line))
1138}
1139
1140fn line_is_managed_state_only(line: &str) -> bool {
1141 let trimmed = line.trim();
1142 if trimmed.is_empty() {
1143 return true;
1144 }
1145 if trimmed.starts_with("<!-- agent:queue")
1147 || trimmed.starts_with("<!-- /agent:queue")
1148 || trimmed.starts_with("<!-- agent:backlog")
1149 || trimmed.starts_with("<!-- /agent:backlog")
1150 || trimmed.starts_with("<!-- agent:done")
1151 || trimmed.starts_with("<!-- /agent:done")
1152 || trimmed.starts_with("<!-- agent:review")
1153 || trimmed.starts_with("<!-- /agent:review")
1154 {
1155 return true;
1156 }
1157 if trimmed.starts_with("queue_active:") {
1159 return true;
1160 }
1161 if trimmed.starts_with("agent_doc_pipeline:")
1166 || trimmed.starts_with("run_id:")
1167 || trimmed.starts_with("step:")
1168 || trimmed.starts_with("turn_id:")
1169 || trimmed.starts_with("queue_task_id:")
1170 {
1171 return true;
1172 }
1173 if trimmed.starts_with("- do ") || trimmed.starts_with("- ~do ") {
1175 return true;
1176 }
1177 if trimmed.starts_with("- [ ]")
1179 || trimmed.starts_with("- [/]")
1180 || trimmed.starts_with("- [x]")
1181 || trimmed.starts_with("- [-]")
1182 || trimmed.starts_with("- [?]")
1183 {
1184 return true;
1185 }
1186 if trimmed.len() >= 12
1188 && trimmed.starts_with("- ")
1189 && trimmed.chars().nth(2).is_some_and(|c| c.is_ascii_digit())
1190 {
1191 return true;
1192 }
1193 if trimmed.starts_with("preset #") || trimmed.starts_with("preset:") {
1195 return true;
1196 }
1197 false
1198}
1199
1200pub fn suppress_inactive_queue_additions(diff: &str, current_content: &str) -> String {
1207 let ranges = queue_line_ranges(current_content);
1208 if ranges.is_empty() {
1209 return diff.to_string();
1210 }
1211
1212 let mut current_line: Option<usize> = None;
1213 let mut filtered = String::new();
1214 for line in diff.lines() {
1215 if line.starts_with("@@ ") {
1216 current_line = hunk_current_start_line(line);
1217 filtered.push_str(line);
1218 filtered.push('\n');
1219 continue;
1220 }
1221
1222 if line.starts_with("--- ") || line.starts_with("+++ ") {
1223 filtered.push_str(line);
1224 filtered.push('\n');
1225 continue;
1226 }
1227
1228 if line.starts_with('+') && !line.starts_with("+++") {
1229 let line_no = current_line.unwrap_or(0);
1230 if current_line_is_in_ranges(line_no, &ranges) {
1231 current_line = current_line.map(|n| n + 1);
1232 continue;
1233 }
1234 current_line = current_line.map(|n| n + 1);
1235 filtered.push_str(line);
1236 filtered.push('\n');
1237 continue;
1238 }
1239
1240 if line.starts_with(' ') {
1241 current_line = current_line.map(|n| n + 1);
1242 }
1243
1244 filtered.push_str(line);
1245 filtered.push('\n');
1246 }
1247
1248 filtered
1249}
1250
1251fn queue_line_ranges(content: &str) -> Vec<(usize, usize)> {
1252 let Ok(components) = component::parse(content) else {
1253 return Vec::new();
1254 };
1255 components
1256 .iter()
1257 .filter(|component| component.name == "queue")
1258 .map(|component| {
1259 (
1260 line_number_at_byte(content, component.open_start),
1261 line_number_at_byte(content, component.close_end.saturating_sub(1)),
1262 )
1263 })
1264 .collect()
1265}
1266
1267fn line_number_at_byte(content: &str, byte_offset: usize) -> usize {
1268 let end = byte_offset.min(content.len());
1269 content[..end].bytes().filter(|b| *b == b'\n').count() + 1
1270}
1271
1272fn hunk_current_start_line(header: &str) -> Option<usize> {
1273 let plus = header
1274 .split_whitespace()
1275 .find(|part| part.starts_with('+'))?;
1276 let number = plus
1277 .trim_start_matches('+')
1278 .split(',')
1279 .next()
1280 .unwrap_or_default();
1281 number.parse::<usize>().ok()
1282}
1283
1284fn current_line_is_in_ranges(line_no: usize, ranges: &[(usize, usize)]) -> bool {
1285 ranges
1286 .iter()
1287 .any(|(start, end)| line_no >= *start && line_no <= *end)
1288}
1289
1290fn prompt_prefix_lines_from_block(block: &str) -> Vec<PromptPrefixLine> {
1291 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
1292 let fc = trimmed.chars().next()?;
1293 if fc != '`' && fc != '~' {
1294 return None;
1295 }
1296 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1297 if fl >= 3 { Some((fc, fl)) } else { None }
1298 }
1299
1300 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
1301 let fc = trimmed.chars().next().unwrap_or('\0');
1302 if fc != fence_char {
1303 return false;
1304 }
1305 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1306 fl >= fence_len && trimmed[fl..].trim().is_empty()
1307 }
1308
1309 let mut lines = Vec::new();
1310 let mut in_fence = false;
1311 let mut fence_char = '`';
1312 let mut fence_len = 3usize;
1313 let mut in_response_block = false;
1314
1315 for line in block.lines() {
1316 let trimmed = line.trim();
1317 if !in_fence {
1318 if let Some((fc, fl)) = fence_open(trimmed) {
1319 in_fence = true;
1320 fence_char = fc;
1321 fence_len = fl;
1322 continue;
1323 }
1324 } else if fence_close(trimmed, fence_char, fence_len) {
1325 in_fence = false;
1326 continue;
1327 }
1328
1329 if in_fence || trimmed.is_empty() || trimmed.starts_with("<!--") {
1330 continue;
1331 }
1332
1333 if line_looks_like_markdown_list_item(trimmed) {
1334 continue;
1335 }
1336
1337 if is_exchange_response_heading(trimmed) {
1338 in_response_block = true;
1339 continue;
1340 }
1341
1342 if in_response_block {
1343 if line_looks_like_targeted_prompt_prefix_repair_start(trimmed, true) {
1344 in_response_block = false;
1345 } else {
1346 continue;
1347 }
1348 }
1349
1350 lines.push(PromptPrefixLine {
1351 raw: line.to_string(),
1352 prefixed: trimmed.starts_with('❯'),
1353 });
1354 }
1355
1356 lines
1357}
1358
1359fn extract_prompt_target_blocks(diff: &str) -> Vec<String> {
1360 fn is_response_heading(line: &str) -> bool {
1361 line.starts_with("### Re:") || line.starts_with("#### Re:") || line.starts_with("## Re:")
1362 }
1363
1364 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
1365 let fc = trimmed.chars().next()?;
1366 if fc != '`' && fc != '~' {
1367 return None;
1368 }
1369 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1370 if fl >= 3 { Some((fc, fl)) } else { None }
1371 }
1372
1373 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
1374 let fc = trimmed.chars().next().unwrap_or('\0');
1375 if fc != fence_char {
1376 return false;
1377 }
1378 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1379 fl >= fence_len && trimmed[fl..].trim().is_empty()
1380 }
1381
1382 fn trim_block_lines(lines: &mut Vec<String>) {
1383 while lines.first().is_some_and(|line| line.trim().is_empty()) {
1384 lines.remove(0);
1385 }
1386 while lines.last().is_some_and(|line| line.trim().is_empty()) {
1387 lines.pop();
1388 }
1389 }
1390
1391 fn block_start(line: &str) -> bool {
1392 let trimmed = line.trim();
1393 if trimmed.is_empty()
1394 || trimmed.starts_with("<!--")
1395 || trimmed.starts_with("```")
1396 || trimmed.starts_with("~~~")
1397 || is_response_heading(trimmed)
1398 {
1399 return false;
1400 }
1401 line_looks_like_prompt_target(trimmed)
1402 }
1403
1404 fn flush(blocks: &mut Vec<String>, current: &mut Vec<String>) {
1405 trim_block_lines(current);
1406 if !current.is_empty() {
1407 blocks.push(current.join("\n"));
1408 current.clear();
1409 }
1410 }
1411
1412 let mut blocks = Vec::new();
1413 let mut current: Vec<String> = Vec::new();
1414 let mut in_fence = false;
1415 let mut fence_char = '`';
1416 let mut fence_len = 3usize;
1417
1418 for line in diff.lines() {
1419 if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
1420 if !in_fence {
1421 flush(&mut blocks, &mut current);
1422 }
1423 continue;
1424 }
1425
1426 let Some(content) = line.strip_prefix('+') else {
1427 if !in_fence {
1428 flush(&mut blocks, &mut current);
1429 }
1430 continue;
1431 };
1432
1433 let trimmed = content.trim();
1434 let starts_new_block = !current.is_empty()
1435 && !in_fence
1436 && ((current.last().is_some_and(|line| line.trim().is_empty()) && !trimmed.is_empty())
1437 || trimmed.starts_with('❯'));
1438 if starts_new_block && current.iter().any(|line| !line.trim().is_empty()) {
1439 flush(&mut blocks, &mut current);
1440 }
1441
1442 if current.is_empty() && !block_start(content) {
1443 continue;
1444 }
1445
1446 current.push(content.to_string());
1447
1448 if !in_fence {
1449 if let Some((fc, fl)) = fence_open(trimmed) {
1450 in_fence = true;
1451 fence_char = fc;
1452 fence_len = fl;
1453 }
1454 } else if fence_close(trimmed, fence_char, fence_len) {
1455 in_fence = false;
1456 }
1457 }
1458
1459 flush(&mut blocks, &mut current);
1460 blocks
1461}
1462
1463fn flush_removes(pending: &mut Vec<String>, result: &mut Vec<String>) {
1465 for removed in pending.drain(..) {
1466 result.push(format!("[user-] {}", removed));
1467 }
1468}
1469
1470fn common_prefix_len(a: &str, b: &str) -> usize {
1472 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
1473}
1474
1475const BUILTIN_COMMAND_NAMES: &[&str] = &[
1478 "/help",
1479 "/model",
1480 "/clear",
1481 "/compact",
1482 "/cost",
1483 "/login",
1484 "/logout",
1485 "/status",
1486 "/config",
1487 "/memory",
1488 "/review",
1489 "/bug",
1490 "/fast",
1491 "/slow",
1492 "/permissions",
1493 "/terminal-setup",
1494 "/doctor",
1495 "/init",
1496 "/pr-comments",
1497 "/vim",
1498 "/diff",
1499 "/undo",
1500 "/resume",
1501 "/listen",
1502 "/mcp",
1503 "/approved-tools",
1504 "/add-dir",
1505 "/release-notes",
1506 "/hooks",
1507 "/btw",
1508];
1509
1510pub fn is_builtin_command(cmd: &str) -> bool {
1512 let cmd_name = cmd.split_whitespace().next().unwrap_or("");
1513 BUILTIN_COMMAND_NAMES.contains(&cmd_name)
1514}
1515
1516pub struct ParsedSlashCommands {
1518 pub skill_commands: Vec<String>,
1520 pub builtin_commands: Vec<String>,
1522}
1523
1524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1526#[serde(rename_all = "snake_case")]
1527pub struct OrchestrationRequest {
1528 pub mode: OrchestrationRequestMode,
1530 pub trigger_text: String,
1532 pub task_count: usize,
1534}
1535
1536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1538#[serde(rename_all = "snake_case")]
1539pub enum OrchestrationRequestMode {
1540 Sequential,
1541 Parallel,
1542 Dag,
1543}
1544
1545pub fn detect_prompt_preset_requests(diff: &str) -> Vec<String> {
1547 let mut requests = Vec::new();
1548 let mut in_fence = false;
1549 let mut fence_char = '`';
1550 let mut fence_len = 0usize;
1551
1552 for line in diff.lines() {
1553 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1554 continue;
1555 }
1556
1557 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1558 &line[1..]
1559 } else {
1560 line
1561 };
1562 let trimmed = content.trim_start();
1563
1564 if !in_fence {
1565 let fc = trimmed.chars().next().unwrap_or('\0');
1566 if fc == '`' || fc == '~' {
1567 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1568 if fl >= 3 {
1569 in_fence = true;
1570 fence_char = fc;
1571 fence_len = fl;
1572 continue;
1573 }
1574 }
1575 } else {
1576 let fc = trimmed.chars().next().unwrap_or('\0');
1577 if fc == fence_char {
1578 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1579 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1580 in_fence = false;
1581 }
1582 }
1583 continue;
1584 }
1585
1586 if !line.starts_with('+') || line.starts_with("+++") {
1587 continue;
1588 }
1589 if content.starts_with('>') {
1590 continue;
1591 }
1592
1593 collect_prompt_preset_requests_from_line(content, &mut requests);
1594 }
1595
1596 requests
1597}
1598
1599pub fn extract_prompt_preset_requests_from_text(text: &str) -> Vec<String> {
1601 let mut requests = Vec::new();
1602 let mut in_fence = false;
1603 let mut fence_char = '`';
1604 let mut fence_len = 0usize;
1605
1606 for line in text.lines() {
1607 let trimmed = line.trim_start();
1608
1609 if !in_fence {
1610 let fc = trimmed.chars().next().unwrap_or('\0');
1611 if fc == '`' || fc == '~' {
1612 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1613 if fl >= 3 {
1614 in_fence = true;
1615 fence_char = fc;
1616 fence_len = fl;
1617 continue;
1618 }
1619 }
1620 } else {
1621 let fc = trimmed.chars().next().unwrap_or('\0');
1622 if fc == fence_char {
1623 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1624 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1625 in_fence = false;
1626 }
1627 }
1628 continue;
1629 }
1630
1631 if trimmed.starts_with('>') {
1632 continue;
1633 }
1634
1635 collect_prompt_preset_requests_from_line(line, &mut requests);
1636 }
1637
1638 requests
1639}
1640
1641fn collect_prompt_preset_requests_from_line(line: &str, requests: &mut Vec<String>) {
1642 let Some(names) = parse_prompt_preset_directive(line) else {
1643 return;
1644 };
1645 for name in names {
1646 if !requests.iter().any(|existing| existing == &name) {
1647 requests.push(name);
1648 }
1649 }
1650}
1651
1652fn parse_prompt_preset_directive(line: &str) -> Option<Vec<String>> {
1653 let mut trimmed = line.trim();
1654 if trimmed.is_empty() {
1655 return None;
1656 }
1657
1658 if let Some(rest) = trimmed.strip_prefix('❯') {
1659 trimmed = rest.trim_start();
1660 }
1661
1662 if let Some(rest) = trimmed
1663 .strip_prefix("- ")
1664 .or_else(|| trimmed.strip_prefix("* "))
1665 .or_else(|| trimmed.strip_prefix("+ "))
1666 {
1667 trimmed = rest.trim_start();
1668 } else {
1669 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
1670 if digits > 0 {
1671 let rest = &trimmed[digits..];
1672 if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
1673 trimmed = rest.trim_start();
1674 }
1675 }
1676 }
1677
1678 let lower = trimmed.to_ascii_lowercase();
1679 let rest = if lower.starts_with("presets ") {
1680 &trimmed["presets ".len()..]
1681 } else if lower.starts_with("preset ") {
1682 &trimmed["preset ".len()..]
1683 } else {
1684 return None;
1685 };
1686
1687 let rest = rest.trim();
1688 if rest.is_empty() {
1689 return None;
1690 }
1691
1692 let mut names = Vec::new();
1693 for segment in rest.split(',') {
1694 let segment = segment.trim();
1695 if segment.is_empty() {
1696 continue;
1697 }
1698 for part in segment.split(" and ") {
1699 let name = part.trim().trim_end_matches(['.', ':', ';']).trim();
1700 if !name.is_empty() {
1701 names.push(name.to_string());
1702 }
1703 }
1704 }
1705
1706 (!names.is_empty()).then_some(names)
1707}
1708
1709pub fn parse_slash_commands_classified(diff: &str) -> ParsedSlashCommands {
1711 let commands = parse_slash_commands(diff);
1712 let mut skill_commands = Vec::new();
1713 let mut builtin_commands = Vec::new();
1714 for cmd in commands {
1715 if is_builtin_command(&cmd) {
1716 builtin_commands.push(cmd);
1717 } else {
1718 skill_commands.push(cmd);
1719 }
1720 }
1721 ParsedSlashCommands {
1722 skill_commands,
1723 builtin_commands,
1724 }
1725}
1726
1727pub fn parse_slash_command_only_added_diff(diff: &str) -> Option<Vec<String>> {
1731 let mut commands = Vec::new();
1732 let mut in_fence = false;
1733 let mut fence_char = '`';
1734 let mut fence_len = 0usize;
1735
1736 for line in diff.lines() {
1737 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1738 continue;
1739 }
1740
1741 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1742 &line[1..]
1743 } else {
1744 line
1745 };
1746 let trimmed = content.trim_start();
1747 let was_in_fence = in_fence;
1748 let mut fence_delimiter = false;
1749
1750 if !in_fence {
1751 let fc = trimmed.chars().next().unwrap_or('\0');
1752 if fc == '`' || fc == '~' {
1753 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1754 if fl >= 3 {
1755 in_fence = true;
1756 fence_char = fc;
1757 fence_len = fl;
1758 fence_delimiter = true;
1759 }
1760 }
1761 } else {
1762 let fc = trimmed.chars().next().unwrap_or('\0');
1763 if fc == fence_char {
1764 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1765 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1766 in_fence = false;
1767 fence_delimiter = true;
1768 }
1769 }
1770 }
1771
1772 if !line.starts_with('+') || line.starts_with("+++") {
1773 continue;
1774 }
1775
1776 if content.trim().is_empty() {
1777 continue;
1778 }
1779 if was_in_fence || fence_delimiter || trimmed.starts_with('>') {
1780 return None;
1781 }
1782 let command = parse_slash_command_candidate(content)?;
1783 commands.push(command);
1784 }
1785
1786 (!commands.is_empty()).then_some(commands)
1787}
1788
1789pub fn parse_slash_commands(diff: &str) -> Vec<String> {
1799 let mut commands = Vec::new();
1800 let mut in_fence = false;
1801 let mut fence_char = '`';
1802 let mut fence_len = 0usize;
1803
1804 for line in diff.lines() {
1805 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1807 continue;
1808 }
1809
1810 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1812 &line[1..]
1813 } else {
1814 line
1815 };
1816
1817 let trimmed = content.trim_start();
1819 if !in_fence {
1820 let fc = trimmed.chars().next().unwrap_or('\0');
1821 if fc == '`' || fc == '~' {
1822 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1823 if fl >= 3 {
1824 in_fence = true;
1825 fence_char = fc;
1826 fence_len = fl;
1827 continue; }
1829 }
1830 } else {
1831 let fc = trimmed.chars().next().unwrap_or('\0');
1833 if fc == fence_char {
1834 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1835 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1836 in_fence = false;
1837 continue; }
1839 }
1840 }
1841
1842 if !line.starts_with('+') || line.starts_with("+++") {
1844 continue;
1845 }
1846
1847 if in_fence {
1849 continue;
1850 }
1851
1852 if content.trim_start().starts_with('>') {
1854 continue;
1855 }
1856
1857 if let Some(command) = parse_slash_command_candidate(content) {
1862 commands.push(command);
1863 }
1864 }
1865
1866 commands
1867}
1868
1869pub fn detect_orchestration_request(diff: &str) -> Option<OrchestrationRequest> {
1876 for block in collect_added_text_blocks(diff) {
1877 let task_count = block
1878 .iter()
1879 .filter(|line| parse_markdown_list_item(line).is_some())
1880 .count();
1881 if task_count < 2 {
1882 continue;
1883 }
1884
1885 let trigger_lines: Vec<&str> = block
1886 .iter()
1887 .map(|line| line.trim())
1888 .filter(|line| !line.is_empty() && parse_markdown_list_item(line).is_none())
1889 .collect();
1890 let trigger_text = if trigger_lines.is_empty() {
1891 block.join(" ")
1892 } else {
1893 trigger_lines.join(" ")
1894 };
1895 let Some(mode) = detect_orchestration_mode(&trigger_text) else {
1896 continue;
1897 };
1898
1899 return Some(OrchestrationRequest {
1900 mode,
1901 trigger_text,
1902 task_count,
1903 });
1904 }
1905
1906 None
1907}
1908
1909pub fn detect_queue_trigger(diff: &str) -> bool {
1915 let mut in_fence = false;
1916 let mut fence_char = '`';
1917 let mut fence_len = 0usize;
1918
1919 for line in diff.lines() {
1920 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1921 continue;
1922 }
1923 if !line.starts_with('+') {
1924 continue;
1925 }
1926 let content = &line[1..];
1927 let trimmed = content.trim_start();
1928
1929 if !in_fence {
1931 let fc = trimmed.chars().next().unwrap_or('\0');
1932 if fc == '`' || fc == '~' {
1933 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1934 if fl >= 3 {
1935 in_fence = true;
1936 fence_char = fc;
1937 fence_len = fl;
1938 continue;
1939 }
1940 }
1941 } else {
1942 let fc = trimmed.chars().next().unwrap_or('\0');
1943 if fc == fence_char {
1944 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1945 if fl >= fence_len {
1946 in_fence = false;
1947 }
1948 }
1949 continue;
1950 }
1951
1952 if trimmed.starts_with('>') {
1954 continue;
1955 }
1956
1957 let text = trimmed.strip_prefix("❯ ").unwrap_or(trimmed);
1959 let lower = text.to_lowercase();
1960
1961 if lower.starts_with("do queue") || lower.starts_with("run queue") {
1962 let after = if lower.starts_with("do queue") {
1963 &text[8..]
1964 } else {
1965 &text[9..]
1966 };
1967 if after.is_empty() || after.starts_with(|c: char| !c.is_alphanumeric() && c != '#') {
1969 return true;
1970 }
1971 }
1972 }
1973 false
1974}
1975
1976pub fn unified_diff_from_contents(previous: &str, current: &str) -> Option<String> {
1980 let previous_stripped = strip_comments(previous);
1981 let current_stripped = strip_comments(current);
1982 let diff = TextDiff::from_lines(&previous_stripped, ¤t_stripped);
1983 let has_changes = diff.iter_all_changes().any(|c| c.tag() != ChangeTag::Equal);
1984 if !has_changes {
1985 return None;
1986 }
1987 Some(
1988 diff.unified_diff()
1989 .context_radius(5)
1990 .header("snapshot", "document")
1991 .to_string(),
1992 )
1993}
1994
1995pub fn synthetic_added_lines_diff(body: &str, target: &str) -> String {
2000 let lines = body.lines().collect::<Vec<_>>();
2001 let count = lines.len().max(1);
2002 let mut diff = format!("--- snapshot\n+++ {target}\n@@ -0,0 +1,{count} @@\n");
2003 if lines.is_empty() {
2004 diff.push_str("+\n");
2005 return diff;
2006 }
2007 for line in lines {
2008 diff.push('+');
2009 diff.push_str(line);
2010 diff.push('\n');
2011 }
2012 diff
2013}
2014
2015pub fn extract_imperative_directives(diff: &str) -> Vec<String> {
2021 let mut directives = Vec::new();
2022 let mut in_fence = false;
2023 let mut fence_char = '`';
2024 let mut fence_len = 0usize;
2025
2026 for line in diff.lines() {
2027 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
2028 continue;
2029 }
2030
2031 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
2032 &line[1..]
2033 } else {
2034 line
2035 };
2036 let trimmed = content.trim_start();
2037
2038 if !in_fence {
2039 let fc = trimmed.chars().next().unwrap_or('\0');
2040 if fc == '`' || fc == '~' {
2041 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2042 if fl >= 3 {
2043 in_fence = true;
2044 fence_char = fc;
2045 fence_len = fl;
2046 continue;
2047 }
2048 }
2049 } else {
2050 let fc = trimmed.chars().next().unwrap_or('\0');
2051 if fc == fence_char {
2052 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2053 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2054 in_fence = false;
2055 continue;
2056 }
2057 }
2058 }
2059
2060 if !line.starts_with('+') || line.starts_with("+++") || in_fence {
2061 continue;
2062 }
2063
2064 if content.starts_with('>') {
2065 continue;
2066 }
2067
2068 let Some(normalized) = normalize_imperative_candidate(content) else {
2069 continue;
2070 };
2071
2072 if looks_like_imperative_directive(&normalized) {
2073 directives.push(normalized);
2074 }
2075 }
2076
2077 directives
2078}
2079
2080pub fn diff_contains_imperative_directive(diff: &str) -> bool {
2083 if !extract_imperative_directives(diff).is_empty() {
2084 return true;
2085 }
2086 matches!(classify_diff(diff).diff_type, DiffType::Approval)
2087}
2088
2089fn trimmed_is_markdown_list_item(trimmed: &str) -> bool {
2094 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
2095 return true;
2096 }
2097 if let Some(dot) = trimmed.find(". ") {
2099 let head = &trimmed[..dot];
2100 if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) {
2101 return true;
2102 }
2103 }
2104 false
2105}
2106
2107pub fn detect_exchange_compaction_request(diff: &str) -> bool {
2116 let mut in_fence = false;
2117 let mut fence_char = '`';
2118 let mut fence_len = 0usize;
2119
2120 for line in diff.lines() {
2121 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
2122 continue;
2123 }
2124
2125 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
2126 &line[1..]
2127 } else {
2128 line
2129 };
2130 let trimmed = content.trim_start();
2131
2132 if !in_fence {
2133 let fc = trimmed.chars().next().unwrap_or('\0');
2134 if fc == '`' || fc == '~' {
2135 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2136 if fl >= 3 {
2137 in_fence = true;
2138 fence_char = fc;
2139 fence_len = fl;
2140 continue;
2141 }
2142 }
2143 } else {
2144 let fc = trimmed.chars().next().unwrap_or('\0');
2145 if fc == fence_char {
2146 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2147 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2148 in_fence = false;
2149 continue;
2150 }
2151 }
2152 }
2153
2154 if !line.starts_with('+')
2155 || line.starts_with("+++")
2156 || in_fence
2157 || content.starts_with('>')
2158 || trimmed_is_markdown_list_item(trimmed)
2159 {
2160 continue;
2167 }
2168
2169 let Some(normalized) = normalize_imperative_candidate(content) else {
2170 continue;
2171 };
2172 let lower = normalized.to_ascii_lowercase();
2173 if lower.starts_with("compact exchange") || lower.starts_with("compact the exchange") {
2174 return true;
2175 }
2176 }
2177
2178 false
2179}
2180
2181#[allow(dead_code)]
2188pub fn extract_required_response_blocks(diff: &str) -> Vec<String> {
2189 extract_prompt_target_blocks(diff)
2190}
2191
2192#[allow(dead_code)]
2194pub fn format_required_response_targets(diff: &str) -> Option<String> {
2195 let blocks = extract_required_response_blocks(diff);
2196 if blocks.is_empty() {
2197 return None;
2198 }
2199
2200 let mut out = String::from(
2201 "Required response targets (oldest first):\n\
2202 Do not stop at the newest question. The turn is incomplete until each item below is answered or explicitly grouped into one response.\n\n",
2203 );
2204 for (idx, block) in blocks.iter().enumerate() {
2205 out.push_str(&format!(
2206 "<target index=\"{}\">\n{}\n</target>\n\n",
2207 idx + 1,
2208 block
2209 ));
2210 }
2211 Some(out)
2212}
2213
2214pub fn format_prompt_bearing_changes(diff: &str) -> Option<String> {
2216 let changes = classify_prompt_bearing_changes(diff);
2217 if changes.is_empty() {
2218 return None;
2219 }
2220
2221 let mut out = String::from(
2222 "User-authored prompt-bearing changes (oldest first):\n\
2223 Do not stop at the newest question. The turn is incomplete until each `prompt_target` item below is answered or explicitly grouped into one response.\n\
2224 Treat `content_edit` items as user corrections to incorporate, and treat `recovery_artifact` / `boundary_artifact` items as document-state signals to normalize rather than ordinary conversation.\n\n",
2225 );
2226 for (idx, change) in changes.iter().enumerate() {
2227 out.push_str(&format!(
2228 "<change index=\"{}\" kind=\"{}\">\n{}\n</change>\n\n",
2229 idx + 1,
2230 serde_json::to_string(&change.kind)
2231 .unwrap_or_else(|_| "\"prompt_target\"".to_string())
2232 .trim_matches('"'),
2233 change.text
2234 ));
2235 }
2236 Some(out)
2237}
2238
2239fn looks_like_imperative_directive(line: &str) -> bool {
2240 let lower = line.to_ascii_lowercase();
2241 let compact = lower.trim();
2242 let exact = compact.trim_end_matches(|c: char| c.is_ascii_punctuation());
2243 if APPROVAL_WORDS.contains(&exact) {
2244 return true;
2245 }
2246 compact.starts_with("do #")
2247 || compact.starts_with("do [#")
2248 || compact.starts_with("fix #")
2249 || compact.starts_with("fix this")
2250 || compact.contains(" run tests")
2251 || compact.starts_with("run tests")
2252 || compact.contains(" run benchmarks")
2253 || compact.starts_with("run benchmarks")
2254 || compact.contains(" build + install")
2255 || compact.starts_with("build + install")
2256 || compact.contains(" build and install")
2257 || compact.starts_with("build and install")
2258 || compact.contains(" commit + push")
2259 || compact.starts_with("commit + push")
2260 || compact.contains(" commit and push")
2261 || compact.starts_with("commit and push")
2262 || starts_with_imperative_verb(compact)
2263}
2264
2265fn normalize_imperative_candidate(line: &str) -> Option<String> {
2266 let mut trimmed = line.trim();
2267 if trimmed.is_empty() {
2268 return None;
2269 }
2270
2271 if let Some(rest) = trimmed.strip_prefix("- ") {
2272 trimmed = rest.trim_start();
2273 }
2274
2275 trimmed = strip_pending_checkbox_prefix(trimmed);
2276
2277 if let Some(rest) = trimmed.strip_prefix("[#")
2278 && let Some(close) = rest.find(']')
2279 {
2280 let id = &rest[..close];
2281 if crate::pending::is_valid_pending_id(id) {
2282 trimmed = rest[close + 1..].trim_start();
2283 }
2284 }
2285
2286 let normalized = trimmed
2287 .trim_start_matches('❯')
2288 .trim_start()
2289 .trim_end_matches(|c: char| c.is_ascii_punctuation() && c != ']')
2290 .trim();
2291 if normalized.is_empty() {
2292 None
2293 } else {
2294 Some(normalized.to_string())
2295 }
2296}
2297
2298fn collect_added_text_blocks(diff: &str) -> Vec<Vec<String>> {
2299 let mut blocks = Vec::new();
2300 let mut current = Vec::new();
2301 let mut in_fence = false;
2302 let mut fence_char = '`';
2303 let mut fence_len = 0usize;
2304 let mut in_recovery_response_tail = false;
2305
2306 for line in diff.lines() {
2307 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
2308 if !current.is_empty() {
2309 blocks.push(std::mem::take(&mut current));
2310 }
2311 in_recovery_response_tail = false;
2312 continue;
2313 }
2314
2315 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
2316 &line[1..]
2317 } else {
2318 line
2319 };
2320 let trimmed = content.trim_start();
2321
2322 if !in_fence {
2323 let fc = trimmed.chars().next().unwrap_or('\0');
2324 if fc == '`' || fc == '~' {
2325 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2326 if fl >= 3 {
2327 in_fence = true;
2328 fence_char = fc;
2329 fence_len = fl;
2330 continue;
2331 }
2332 }
2333 } else {
2334 let fc = trimmed.chars().next().unwrap_or('\0');
2335 if fc == fence_char {
2336 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2337 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2338 in_fence = false;
2339 }
2340 }
2341 continue;
2342 }
2343
2344 if !line.starts_with('+') || line.starts_with("+++") {
2345 if !current.is_empty() {
2346 blocks.push(std::mem::take(&mut current));
2347 }
2348 in_recovery_response_tail = false;
2349 continue;
2350 }
2351
2352 if content.starts_with('>') {
2353 continue;
2354 }
2355
2356 if is_exchange_response_heading(trimmed) {
2357 if !current.is_empty() {
2358 blocks.push(std::mem::take(&mut current));
2359 }
2360 in_recovery_response_tail = true;
2361 continue;
2362 }
2363
2364 if in_recovery_response_tail {
2365 if line_looks_like_targeted_prompt_prefix_repair_start(
2366 trimmed,
2367 line_looks_like_prompt_target(trimmed),
2368 ) {
2369 in_recovery_response_tail = false;
2370 } else {
2371 continue;
2372 }
2373 }
2374
2375 current.push(content.trim_end().to_string());
2376 }
2377
2378 if !current.is_empty() {
2379 blocks.push(current);
2380 }
2381
2382 blocks
2383}
2384
2385fn parse_markdown_list_item(line: &str) -> Option<&str> {
2386 let trimmed = strip_prompt_prefix(line.trim_start());
2387 for prefix in ["- ", "* ", "+ "] {
2388 if let Some(rest) = trimmed.strip_prefix(prefix) {
2389 let rest = rest.trim();
2390 return (!rest.is_empty()).then_some(rest);
2391 }
2392 }
2393
2394 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
2395 if digits > 0 {
2396 let rest = &trimmed[digits..];
2397 if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
2398 let rest = rest.trim();
2399 return (!rest.is_empty()).then_some(rest);
2400 }
2401 }
2402
2403 None
2404}
2405
2406pub fn line_looks_like_markdown_list_item(line: &str) -> bool {
2407 parse_markdown_list_item(line).is_some()
2408}
2409
2410fn strip_prompt_prefix(line: &str) -> &str {
2411 line.strip_prefix("❯ ")
2412 .or_else(|| line.strip_prefix('❯'))
2413 .map(str::trim_start)
2414 .unwrap_or(line)
2415}
2416
2417fn detect_orchestration_mode(text: &str) -> Option<OrchestrationRequestMode> {
2418 let lower = text.to_ascii_lowercase();
2419
2420 if contains_any(
2421 &lower,
2422 &[
2423 "dependency graph",
2424 "depends on",
2425 "blocked by",
2426 "prerequisite",
2427 "then unblock",
2428 ],
2429 ) || lower.contains("after #")
2430 {
2431 return Some(OrchestrationRequestMode::Dag);
2432 }
2433
2434 if contains_any(
2435 &lower,
2436 &[
2437 "fan out",
2438 "concurrent",
2439 "at the same time",
2440 "in parallel",
2441 "simultaneously",
2442 "parallelize",
2443 ],
2444 ) {
2445 return Some(OrchestrationRequestMode::Parallel);
2446 }
2447
2448 if contains_any(
2449 &lower,
2450 &[
2451 "orchestra",
2452 "orcestra",
2453 "orchestrate",
2454 "chain",
2455 "in order",
2456 "one by one",
2457 "sequential",
2458 "sequentially",
2459 "synchronous",
2460 "run these sequentially",
2461 ],
2462 ) {
2463 return Some(OrchestrationRequestMode::Sequential);
2464 }
2465
2466 None
2467}
2468
2469fn contains_any(text: &str, needles: &[&str]) -> bool {
2470 needles.iter().any(|needle| text.contains(needle))
2471}
2472
2473fn strip_pending_checkbox_prefix(line: &str) -> &str {
2474 let trimmed = line.trim_start();
2475 for prefix in ["[ ]", "[/]", "[x]", "[X]"] {
2476 if let Some(rest) = trimmed.strip_prefix(prefix) {
2477 return rest.trim_start();
2478 }
2479 }
2480 if let Some(inner) = trimmed.strip_prefix("[/")
2481 && let Some(close) = inner.find(']')
2482 {
2483 let gate_type = &inner[..close];
2484 if !gate_type.is_empty()
2485 && gate_type
2486 .chars()
2487 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2488 {
2489 return inner[close + 1..].trim_start();
2490 }
2491 }
2492 trimmed
2493}
2494
2495fn starts_with_imperative_verb(line: &str) -> bool {
2496 let mut words = line.split_whitespace();
2497 let Some(first) = words.next() else {
2498 return false;
2499 };
2500 if IMPERATIVE_LEADING_VERBS.contains(&first) {
2501 return true;
2502 }
2503 matches!((first, words.next()), ("clean", Some("up")))
2504}
2505
2506pub fn is_stale_snapshot(snapshot_content: &str, document_content: &str) -> bool {
2516 let snap_stripped = strip_comments(snapshot_content);
2517 let doc_stripped = strip_comments(document_content);
2518
2519 if doc_stripped.len() <= snap_stripped.len() {
2521 return false;
2522 }
2523
2524 let snap_trimmed = snap_stripped.trim_end();
2527 let doc_trimmed = doc_stripped.trim_end();
2528
2529 if !doc_trimmed.starts_with(snap_trimmed) {
2530 return false;
2531 }
2532
2533 let extra = &doc_stripped[snap_trimmed.len()..];
2535 let extra_trimmed = extra.trim();
2536
2537 if extra_trimmed.is_empty() {
2538 return false;
2539 }
2540
2541 if !extra_trimmed.contains("## Assistant") {
2543 return false;
2544 }
2545
2546 let parts: Vec<&str> = extra_trimmed.split("## User").collect();
2549 if let Some(last_user_block) = parts.last() {
2550 let user_content = last_user_block.trim();
2551 user_content.is_empty()
2554 } else {
2555 false
2557 }
2558}
2559
2560const APPROVAL_WORDS: &[&str] = &[
2566 "go", "yes", "do", "ok", "continue", "approve", "approved", "y", "yep", "yeah", "sure",
2567 "proceed", "lgtm",
2568];
2569
2570pub fn classify_diff(diff_text: &str) -> DiffClassification {
2574 let mut added: Vec<String> = Vec::new();
2576 let mut removed: Vec<String> = Vec::new();
2577 let mut added_block_count = 0usize;
2578 let mut in_added_block = false;
2579
2580 for line in diff_text.lines() {
2581 if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
2583 in_added_block = false;
2584 continue;
2585 }
2586 if let Some(content) = line.strip_prefix('+') {
2587 added.push(content.to_string());
2588 if !in_added_block {
2589 added_block_count += 1;
2590 in_added_block = true;
2591 }
2592 } else if let Some(content) = line.strip_prefix('-') {
2593 removed.push(content.to_string());
2594 in_added_block = false;
2595 } else {
2596 in_added_block = false;
2598 }
2599 }
2600
2601 let added_content: Vec<&str> = added
2603 .iter()
2604 .map(|s| s.trim())
2605 .filter(|s| !s.is_empty())
2606 .collect();
2607 let removed_content: Vec<&str> = removed
2608 .iter()
2609 .map(|s| s.trim())
2610 .filter(|s| !s.is_empty())
2611 .collect();
2612
2613 if is_boundary_artifact(&added_content, &removed_content) {
2615 return DiffClassification {
2616 diff_type: DiffType::BoundaryArtifact,
2617 diff_type_reason: "only boundary marker or (HEAD) changes".into(),
2618 };
2619 }
2620
2621 if added_content.len() == 1 && removed_content.is_empty() {
2623 let word = added_content[0].to_lowercase();
2624 let word_clean = word.trim_end_matches(|c: char| c.is_ascii_punctuation());
2626 if APPROVAL_WORDS.contains(&word_clean) {
2627 return DiffClassification {
2628 diff_type: DiffType::Approval,
2629 diff_type_reason: format!("single approval word: \"{}\"", added_content[0]),
2630 };
2631 }
2632 }
2633
2634 if added_content.len() == 1 && removed_content.is_empty() && added_content[0].ends_with('?') {
2636 return DiffClassification {
2637 diff_type: DiffType::SimpleQuestion,
2638 diff_type_reason: format!(
2639 "single question: \"{}\"",
2640 truncate_for_reason(added_content[0])
2641 ),
2642 };
2643 }
2644
2645 if !added_content.is_empty()
2647 && !removed_content.is_empty()
2648 && is_annotation(&added_content, &removed_content)
2649 {
2650 return DiffClassification {
2651 diff_type: DiffType::Annotation,
2652 diff_type_reason: "inline edit to existing content".into(),
2653 };
2654 }
2655
2656 if added_content.is_empty() && !removed_content.is_empty() {
2658 return DiffClassification {
2659 diff_type: DiffType::StructuralChange,
2660 diff_type_reason: format!("{} lines removed, no additions", removed_content.len()),
2661 };
2662 }
2663
2664 let has_separator = added.iter().any(|l| l.trim() == "---");
2666 if has_separator && added_content.len() >= 2 {
2667 let section_count = added_content
2668 .split(|l| *l == "---")
2669 .filter(|s| !s.is_empty())
2670 .count();
2671 if section_count >= 2 {
2672 return DiffClassification {
2673 diff_type: DiffType::MultiTopic,
2674 diff_type_reason: format!("{} topics separated by ---", section_count),
2675 };
2676 }
2677 }
2678 if added_block_count >= 2 && added_content.len() >= 2 {
2679 return DiffClassification {
2680 diff_type: DiffType::MultiTopic,
2681 diff_type_reason: format!("{} distinct added blocks", added_block_count),
2682 };
2683 }
2684
2685 DiffClassification {
2687 diff_type: DiffType::ContentAddition,
2688 diff_type_reason: format!(
2689 "{} lines added{}",
2690 added_content.len(),
2691 if !removed_content.is_empty() {
2692 format!(", {} removed", removed_content.len())
2693 } else {
2694 String::new()
2695 }
2696 ),
2697 }
2698}
2699
2700fn is_boundary_artifact(added: &[&str], removed: &[&str]) -> bool {
2702 let is_boundary_line =
2703 |line: &str| -> bool { is_boundary_artifact_line(line) || line.is_empty() };
2704 if added.is_empty() && removed.is_empty() {
2706 return false;
2707 }
2708 if added.iter().all(|l| is_boundary_line(l)) && removed.iter().all(|l| is_boundary_line(l)) {
2710 return true;
2711 }
2712 if added.len() == removed.len() {
2714 return added.iter().zip(removed.iter()).all(|(a, r)| {
2715 let a_trim = a.trim();
2716 let r_trim = r.trim();
2717 if a_trim == r_trim {
2718 return true;
2719 }
2720 if a_trim.starts_with("<!-- agent:boundary:")
2721 && r_trim.starts_with("<!-- agent:boundary:")
2722 {
2723 return true;
2724 }
2725 if is_head_boundary_artifact(a_trim)
2726 && a_trim
2727 .strip_suffix(" (HEAD)")
2728 .is_some_and(|base| base.trim() == r_trim)
2729 {
2730 return true;
2731 }
2732 if is_head_boundary_artifact(r_trim)
2733 && r_trim
2734 .strip_suffix(" (HEAD)")
2735 .is_some_and(|base| base.trim() == a_trim)
2736 {
2737 return true;
2738 }
2739 false
2740 });
2741 }
2742 false
2743}
2744
2745fn is_annotation(added: &[&str], removed: &[&str]) -> bool {
2750 if added.len() != removed.len() {
2751 return false;
2752 }
2753 added.iter().zip(removed.iter()).all(|(a, r)| {
2754 a.starts_with(r)
2756 || {
2758 let min_len = a.len().min(r.len());
2759 if min_len < 3 {
2760 return false;
2761 }
2762 let common = a
2763 .chars()
2764 .zip(r.chars())
2765 .take_while(|(a, b)| a == b)
2766 .count();
2767 common > min_len * 6 / 10
2768 }
2769 })
2770}
2771
2772fn truncate_for_reason(s: &str) -> &str {
2774 if s.len() <= 80 { s } else { &s[..80] }
2775}
2776
2777#[cfg(test)]
2778mod tests {
2779 use super::*;
2780
2781 #[test]
2782 fn diff_format_additions() {
2783 use similar::{ChangeTag, TextDiff};
2784 let previous = "line1\n";
2785 let current = "line1\nline2\n";
2786 let diff = TextDiff::from_lines(previous, current);
2787 let has_insert = diff
2788 .iter_all_changes()
2789 .any(|c| c.tag() == ChangeTag::Insert);
2790 assert!(has_insert);
2791 }
2792
2793 #[test]
2794 fn diff_format_deletions() {
2795 use similar::{ChangeTag, TextDiff};
2796 let previous = "line1\nline2\n";
2797 let current = "line1\n";
2798 let diff = TextDiff::from_lines(previous, current);
2799 let has_delete = diff
2800 .iter_all_changes()
2801 .any(|c| c.tag() == ChangeTag::Delete);
2802 assert!(has_delete);
2803 }
2804
2805 #[test]
2806 fn diff_format_unchanged() {
2807 use similar::{ChangeTag, TextDiff};
2808 let content = "line1\nline2\n";
2809 let diff = TextDiff::from_lines(content, content);
2810 let all_equal = diff.iter_all_changes().all(|c| c.tag() == ChangeTag::Equal);
2811 assert!(all_equal);
2812 }
2813
2814 #[test]
2815 fn diff_format_mixed() {
2816 use similar::{ChangeTag, TextDiff};
2817 let previous = "line1\nline2\nline3\n";
2818 let current = "line1\nchanged\nline3\n";
2819 let diff = TextDiff::from_lines(previous, current);
2820
2821 let mut output = String::new();
2822 for change in diff.iter_all_changes() {
2823 let prefix = match change.tag() {
2824 ChangeTag::Delete => "-",
2825 ChangeTag::Insert => "+",
2826 ChangeTag::Equal => " ",
2827 };
2828 output.push_str(prefix);
2829 output.push_str(change.value());
2830 }
2831 assert!(output.contains(" line1\n"));
2832 assert!(output.contains("-line2\n"));
2833 assert!(output.contains("+changed\n"));
2834 assert!(output.contains(" line3\n"));
2835 }
2836
2837 #[test]
2840 fn strip_html_comment() {
2841 let input = "before\n<!-- a comment -->\nafter\n";
2842 assert_eq!(strip_comments(input), "before\nafter\n");
2843 }
2844
2845 #[test]
2846 fn strip_multiline_html_comment() {
2847 let input = "before\n<!--\nmulti\nline\n-->\nafter\n";
2848 assert_eq!(strip_comments(input), "before\nafter\n");
2849 }
2850
2851 #[test]
2852 fn strip_link_ref_comment() {
2853 let input = "before\n[//]: # (a comment)\nafter\n";
2854 assert_eq!(strip_comments(input), "before\nafter\n");
2855 }
2856
2857 #[test]
2858 fn preserve_agent_markers() {
2859 let input = "<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
2860 assert_eq!(strip_comments(input), input);
2861 }
2862
2863 #[test]
2864 fn strip_regular_keep_agent_marker() {
2865 let input = "<!-- regular comment -->\n<!-- agent:s -->\ndata\n<!-- /agent:s -->\n";
2866 assert_eq!(
2867 strip_comments(input),
2868 "<!-- agent:s -->\ndata\n<!-- /agent:s -->\n"
2869 );
2870 }
2871
2872 #[test]
2873 fn strip_inline_comment() {
2874 let input = "text <!-- note --> more\n";
2876 let result = strip_comments(input);
2877 assert_eq!(result, "text more\n");
2878 }
2879
2880 #[test]
2881 fn no_comments_unchanged() {
2882 let input = "# Title\n\nJust text.\n";
2883 assert_eq!(strip_comments(input), input);
2884 }
2885
2886 #[test]
2887 fn empty_document() {
2888 assert_eq!(strip_comments(""), "");
2889 }
2890
2891 #[test]
2894 fn stale_snapshot_detects_completed_exchange() {
2895 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2896 let document = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nWhat's up\n\n## Assistant\n\nNot much\n\n## User\n\n";
2897 assert!(is_stale_snapshot(snapshot, document));
2898 }
2899
2900 #[test]
2901 fn stale_snapshot_false_when_user_has_new_content() {
2902 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2903 let document =
2904 "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nNew question here\n";
2905 assert!(!is_stale_snapshot(snapshot, document));
2906 }
2907
2908 #[test]
2909 fn stale_snapshot_false_when_identical() {
2910 let content = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
2911 assert!(!is_stale_snapshot(content, content));
2912 }
2913
2914 #[test]
2915 fn stale_snapshot_false_when_no_assistant_block() {
2916 let snapshot = "## User\n\nHello\n\n";
2917 let document = "## User\n\nHello\n\nSome random text\n\n## User\n\n";
2918 assert!(!is_stale_snapshot(snapshot, document));
2919 }
2920
2921 #[test]
2922 fn stale_snapshot_multiple_exchanges_stale() {
2923 let snapshot = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\n";
2924 let document = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\nQ2\n\n## Assistant\n\nA2\n\n## User\n\nQ3\n\n## Assistant\n\nA3\n\n## User\n\n";
2925 assert!(is_stale_snapshot(snapshot, document));
2926 }
2927
2928 #[test]
2929 fn stale_snapshot_with_inline_annotation_not_stale() {
2930 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2931 let document =
2933 "## User\n\nHello\n\n## Assistant\n\nHi there\n\nPlease elaborate\n\n## User\n\n";
2934 assert!(!is_stale_snapshot(snapshot, document));
2936 }
2937
2938 #[test]
2939 fn stale_snapshot_ignores_comments_in_detection() {
2940 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
2941 let document = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n<!-- scratch -->\n\n## Assistant\n\nResponse\n\n## User\n\n";
2942 assert!(is_stale_snapshot(snapshot, document));
2944 }
2945
2946 #[test]
2947 fn copy_on_read_guard_skips_recovery_when_snapshot_modified() {
2948 use std::time::SystemTime;
2951
2952 let t1 = Some(SystemTime::UNIX_EPOCH);
2953 let t2 = Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1));
2954
2955 assert_eq!(t1, t1, "same mtime should allow recovery");
2957
2958 assert_ne!(t1, t2, "different mtime should block recovery");
2960
2961 let none: Option<SystemTime> = None;
2963 assert_eq!(none, none, "both None should allow recovery");
2964 }
2965
2966 #[test]
2969 fn strip_preserves_comment_syntax_in_inline_backticks() {
2970 let input =
2972 "Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n";
2973 let result = strip_comments(input);
2974 assert_eq!(
2975 result,
2976 "Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n"
2977 );
2978 }
2979
2980 #[test]
2981 fn strip_preserves_comment_syntax_in_fenced_code_block() {
2982 let input = "before\n```\n<!-- not a comment -->\n```\nafter\n";
2983 let result = strip_comments(input);
2984 assert_eq!(result, input);
2985 }
2986
2987 #[test]
2988 fn strip_backtick_comment_before_agent_marker() {
2989 let input = "\
2992Text mentions `<!--` as a trigger.\n\
2993More text here.\n\
2994New user content.\n\
2995<!-- /agent:exchange -->\n";
2996 let result = strip_comments(input);
2997 assert_eq!(result, input);
2998 }
2999
3000 #[test]
3001 fn strip_multiple_backtick_comments_in_exchange() {
3002 let snapshot = "\
3004<!-- agent:exchange -->\n\
3005Discussion about `<!--` triggers.\n\
3006- `<!-- agent:NAME -->` paired markers\n\
3007<!-- /agent:exchange -->\n";
3008 let current = "\
3009<!-- agent:exchange -->\n\
3010Discussion about `<!--` triggers.\n\
3011- `<!-- agent:NAME -->` paired markers\n\
3012\n\
3013Please fix the bug.\n\
3014<!-- /agent:exchange -->\n";
3015
3016 let snap_stripped = strip_comments(snapshot);
3017 let curr_stripped = strip_comments(current);
3018 assert_ne!(
3019 snap_stripped, curr_stripped,
3020 "inline edits after backtick-comment text must be detected"
3021 );
3022 }
3023
3024 fn make_diff(added: &[&str], removed: &[&str]) -> String {
3027 let mut lines = vec!["--- snapshot", "+++ document", "@@ -1,5 +1,5 @@"];
3028 for r in removed {
3029 lines.push(r);
3030 }
3031 lines.push(" context line");
3032 for a in added {
3033 lines.push(a);
3034 }
3035 lines.join("\n")
3036 }
3037
3038 #[test]
3039 fn classify_approval() {
3040 let diff = make_diff(&["+go"], &[]);
3041 let c = classify_diff(&diff);
3042 assert_eq!(c.diff_type, DiffType::Approval);
3043 assert!(c.diff_type_reason.contains("go"));
3044 }
3045
3046 #[test]
3047 fn classify_approval_case_insensitive() {
3048 let diff = make_diff(&["+Yes"], &[]);
3049 let c = classify_diff(&diff);
3050 assert_eq!(c.diff_type, DiffType::Approval);
3051 }
3052
3053 #[test]
3054 fn classify_simple_question() {
3055 let diff = make_diff(&["+what is the release process?"], &[]);
3056 let c = classify_diff(&diff);
3057 assert_eq!(c.diff_type, DiffType::SimpleQuestion);
3058 }
3059
3060 #[test]
3061 fn classify_boundary_artifact() {
3062 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-### Re: Something (HEAD)\n+### Re: Something\n";
3063 let c = classify_diff(diff);
3064 assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
3065 }
3066
3067 #[test]
3068 fn classify_boundary_uuid() {
3069 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-<!-- agent:boundary:abc123 -->\n+<!-- agent:boundary:def456 -->\n";
3070 let c = classify_diff(diff);
3071 assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
3072 }
3073
3074 #[test]
3075 fn classify_head_mention_in_user_prose_as_content_addition() {
3076 let diff = make_diff(
3077 &[
3078 "+`❯ ` prompt prefix is being stripped away by the uncommitted user affordance that adds the ` (HEAD)` suffix. spec-test-build-install-commit-push",
3079 ],
3080 &[],
3081 );
3082 let c = classify_diff(&diff);
3083 assert_eq!(c.diff_type, DiffType::ContentAddition);
3084 }
3085
3086 #[test]
3087 fn classify_structural_change() {
3088 let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,3 @@\n context\n-removed line one\n-removed line two\n context\n";
3089 let c = classify_diff(diff);
3090 assert_eq!(c.diff_type, DiffType::StructuralChange);
3091 }
3092
3093 #[test]
3094 fn classify_multi_topic() {
3095 let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,7 @@\n context\n+first topic\n context middle\n+second topic\n context end\n";
3097 let c = classify_diff(diff);
3098 assert_eq!(c.diff_type, DiffType::MultiTopic);
3099 }
3100
3101 #[test]
3102 fn classify_multi_topic_with_separator() {
3103 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n context\n+question one?\n+---\n+do something else\n context end\n";
3105 let c = classify_diff(diff);
3106 assert_eq!(c.diff_type, DiffType::MultiTopic);
3107 }
3108
3109 #[test]
3110 fn classify_content_addition() {
3111 let diff = make_diff(&["+implement the feature using Rust"], &[]);
3112 let c = classify_diff(&diff);
3113 assert_eq!(c.diff_type, DiffType::ContentAddition);
3114 }
3115
3116 #[test]
3117 fn classify_annotation() {
3118 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n context\n-The fix is deployed\n+The fix is deployed: confirmed working in prod\n context\n";
3119 let c = classify_diff(diff);
3120 assert_eq!(c.diff_type, DiffType::Annotation);
3121 }
3122
3123 #[test]
3124 fn classify_approval_in_sentence_is_content() {
3125 let diff = make_diff(&["+let's go ahead and implement the feature"], &[]);
3127 let c = classify_diff(&diff);
3128 assert_eq!(c.diff_type, DiffType::ContentAddition);
3129 }
3130
3131 #[test]
3132 fn classify_single_separator_not_multi_topic() {
3133 let diff = make_diff(&["+---"], &[]);
3135 let c = classify_diff(&diff);
3136 assert_ne!(c.diff_type, DiffType::MultiTopic);
3138 }
3139
3140 #[test]
3141 fn classify_question_mark_in_multiline_is_content() {
3142 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n context\n+first line of paragraph\n+is this a question?\n context\n";
3144 let c = classify_diff(diff);
3145 assert_ne!(c.diff_type, DiffType::SimpleQuestion);
3148 }
3149
3150 #[test]
3153 fn annotate_diff_additions() {
3154 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n context line\n+new user line\n more context\n";
3155 let annotated = annotate_diff(diff).unwrap();
3156 assert!(annotated.contains("[user+] new user line"));
3157 assert!(annotated.contains("[agent] context line"));
3158 }
3159
3160 #[test]
3161 fn annotate_diff_removals() {
3162 let diff =
3163 "--- snapshot\n+++ document\n@@ -1,3 +1,2 @@\n context\n-removed line\n context\n";
3164 let annotated = annotate_diff(diff).unwrap();
3165 assert!(annotated.contains("[user-] removed line"));
3166 }
3167
3168 #[test]
3169 fn annotate_diff_modifications() {
3170 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n context\n-The fix is deployed\n+The fix is deployed: confirmed in prod\n context\n";
3171 let annotated = annotate_diff(diff).unwrap();
3172 assert!(annotated.contains("[user~] The fix is deployed: confirmed in prod"));
3173 assert!(!annotated.contains("[user-] The fix is deployed"));
3175 }
3176
3177 #[test]
3178 fn annotate_diff_context() {
3179 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n line one\n line two\n+added\n line three\n";
3180 let annotated = annotate_diff(diff).unwrap();
3181 assert!(annotated.contains("[agent] line one"));
3182 assert!(annotated.contains("[agent] line two"));
3183 assert!(annotated.contains("[agent] line three"));
3184 }
3185
3186 #[test]
3187 fn annotate_diff_empty() {
3188 let diff = "--- snapshot\n+++ document\n";
3189 assert!(annotate_diff(diff).is_none());
3190 }
3191
3192 #[test]
3195 fn inline_annotations_user_addition_between_agent_lines() {
3196 let annotated = "[agent] previous agent line\n\
3197 [user+] This is wrong, fix it\n\
3198 [agent] more agent content";
3199 let anns = extract_inline_annotations(annotated);
3200 assert_eq!(anns, vec!["This is wrong, fix it"]);
3201 }
3202
3203 #[test]
3204 fn inline_annotations_user_modification_between_agent_lines() {
3205 let annotated = "[agent] context before\n\
3206 [user~] The corrected line\n\
3207 [agent] context after";
3208 let anns = extract_inline_annotations(annotated);
3209 assert_eq!(anns, vec!["The corrected line"]);
3210 }
3211
3212 #[test]
3213 fn inline_annotations_user_addition_at_end_is_not_inline() {
3214 let annotated = "[agent] agent content\n\
3215 [agent] more agent content\n\
3216 [user+] New user input at end";
3217 let anns = extract_inline_annotations(annotated);
3218 assert!(anns.is_empty());
3219 }
3220
3221 #[test]
3222 fn inline_annotations_component_markers_not_substantive() {
3223 let annotated = "[agent] response prose\n\
3225 [user+] The fix did not seem to work\n\
3226 [agent] <!-- /agent:exchange -->\n\
3227 [agent] \n\
3228 [agent] ## Pending / Not Built\n\
3229 [agent] \n\
3230 [agent] <!-- agent:pending -->";
3231 let anns = extract_inline_annotations(annotated);
3232 assert!(
3233 anns.is_empty(),
3234 "component markers should not make end-of-exchange input inline"
3235 );
3236 }
3237
3238 #[test]
3239 fn inline_annotations_head_boundary_artifact_excluded() {
3240 let annotated = "[agent] previous content\n\
3242 [user~] ### Re: topic — sonnet-4-6 (HEAD)\n\
3243 [agent] response prose\n\
3244 [agent] more content";
3245 let anns = extract_inline_annotations(annotated);
3246 assert!(
3247 anns.is_empty(),
3248 "(HEAD) boundary reposition should not be an inline annotation"
3249 );
3250 }
3251
3252 #[test]
3253 fn inline_annotations_real_correction_between_prose() {
3254 let annotated = "[agent] The score is 5 out of 10.\n\
3256 [user+] This is wrong. Score should be 8-9.\n\
3257 [agent] Here is more analysis below.";
3258 let anns = extract_inline_annotations(annotated);
3259 assert_eq!(anns, vec!["This is wrong. Score should be 8-9."]);
3260 }
3261
3262 #[test]
3263 fn inline_annotations_multiple() {
3264 let annotated = "[agent] ### Re: topic\n\
3265 [user+] First correction\n\
3266 [agent] agent paragraph\n\
3267 [user+] Second correction\n\
3268 [agent] agent closing\n\
3269 [user+] New input at end";
3270 let anns = extract_inline_annotations(annotated);
3271 assert_eq!(anns, vec!["First correction", "Second correction"]);
3272 }
3273
3274 #[test]
3275 fn inline_annotations_skips_blank_user_lines() {
3276 let annotated = "[agent] agent line\n\
3277 [user+] \n\
3278 [agent] more agent";
3279 let anns = extract_inline_annotations(annotated);
3280 assert!(anns.is_empty());
3281 }
3282
3283 #[test]
3284 fn inline_annotations_empty_annotated_diff() {
3285 let anns = extract_inline_annotations("");
3286 assert!(anns.is_empty());
3287 }
3288
3289 #[test]
3290 fn inline_annotations_claudescore_table_scenario() {
3291 let annotated = "[agent] | Category | Score |\n\
3294 [agent] |----------|-------|\n\
3295 [agent] | Quality | 7 |\n\
3296 [user+] This is wrong. Do not lower expert scores.\n\
3297 [agent] | Speed | 8 |\n\
3298 [user+] We may need to broaden the gate.\n\
3299 [agent] | Total | 7.5 |";
3300 let anns = extract_inline_annotations(annotated);
3301 assert_eq!(
3302 anns,
3303 vec![
3304 "This is wrong. Do not lower expert scores.",
3305 "We may need to broaden the gate.",
3306 ]
3307 );
3308 }
3309
3310 #[test]
3311 fn classify_prompt_bearing_changes_promotes_inline_prompt_to_prompt_target() {
3312 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3313 The prior explanation was incomplete\n\
3314 +Why was the `❯` prefix omitted here?\n\
3315 The rest of the response stays the same\n";
3316 let changes = classify_prompt_bearing_changes(diff);
3317 assert_eq!(changes.len(), 1);
3318 assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3319 assert_eq!(changes[0].text, "Why was the `❯` prefix omitted here?");
3320 }
3321
3322 #[test]
3323 fn classify_prompt_bearing_changes_promotes_plain_exchange_tail_to_prompt_target() {
3324 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3325Done.\n\
3326+When I run `Run Agent Doc` on this document...nothing happens. Please diagnose the root cause failure and fix the root cause. spec-test-build-install-commit-push\n\
3327<!-- /agent:exchange -->\n";
3328 let changes = classify_prompt_bearing_changes(diff);
3329 assert_eq!(changes.len(), 1);
3330 assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3331 assert_eq!(
3332 changes[0].text,
3333 "When I run `Run Agent Doc` on this document...nothing happens. Please diagnose the root cause failure and fix the root cause. spec-test-build-install-commit-push"
3334 );
3335 }
3336
3337 #[test]
3338 fn classify_prompt_bearing_changes_promotes_bare_slash_command_to_prompt_target() {
3339 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3340### Re: older — gpt-5\n\
3341+/clear\n\
3342<!-- /agent:exchange -->\n";
3343 let changes = classify_prompt_bearing_changes(diff);
3344 assert_eq!(changes.len(), 1);
3345 assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3346 assert_eq!(changes[0].text, "/clear");
3347 assert!(line_looks_like_fresh_prompt_after_response("/clear"));
3348 assert!(!text_line_looks_like_prompt_target(
3349 "/home/brian/work/foo.md"
3350 ));
3351 }
3352
3353 #[test]
3354 fn prefixed_markdown_response_labels_are_not_prompt_targets() {
3355 for line in [
3356 "❯ **Verification:** Both redirects confirmed via `curl`.",
3357 "❯ Commit / push:",
3358 "❯ **Commit / push:**",
3359 "❯ - **Verification:** `cargo test` passed.",
3360 "❯ 1. **What changed:** normalized response labels.",
3361 ] {
3362 assert!(
3363 !text_line_looks_like_prompt_target(line),
3364 "assistant response label must not be classified as a prompt target: {line}"
3365 );
3366 assert!(
3367 line_looks_like_plain_response_after_prompt(line),
3368 "assistant response label must remain response prose: {line}"
3369 );
3370 }
3371 }
3372
3373 #[test]
3374 fn prefixed_user_followup_after_response_still_starts_prompt() {
3375 for line in [
3376 "❯ verify deploy status",
3377 "❯ Verification failed; what next?",
3378 "❯ do [#respfx]. spec-test-build-install-commit-push",
3379 ] {
3380 assert!(
3381 text_line_looks_like_prompt_target(line),
3382 "real user follow-up must stay prompt-bearing: {line}"
3383 );
3384 assert!(
3385 line_looks_like_fresh_prompt_after_response(line),
3386 "real user follow-up must start a new prompt run: {line}"
3387 );
3388 }
3389 }
3390
3391 #[test]
3392 fn classify_prompt_bearing_changes_ignores_prefixed_markdown_response_label() {
3393 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3394 ### Re: done — gpt-5\n\
3395 +❯ **Verification:** Both redirects confirmed via `curl`.\n\
3396 <!-- /agent:exchange -->\n";
3397 let changes = classify_prompt_bearing_changes(diff);
3398 assert!(
3399 changes.is_empty(),
3400 "prefixed assistant response label must not reopen a cycle: {changes:?}"
3401 );
3402 }
3403
3404 #[test]
3405 fn classify_prompt_bearing_changes_ignores_prefixed_recovery_evidence() {
3406 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,11 @@\n\
3407 ctx\n\
3408 +### Re: stalled Run Agent Doc recovery — gpt-5\n\
3409 +\n\
3410 +Recovered the stranded closeout first.\n\
3411 +\n\
3412 +❯ - `./gradlew test --tests 'TerminalUtilTest.protected prompt input route refusal is actionable not persistent failure'`\n\
3413 +❯ - `cargo test -p agent-doc-orchestration protected_prompt_draft_preview_redacts_and_bounds_latest_draft`\n\
3414 +❯ - `612b1552` `test: cover protected prompt route refusal`\n\
3415 +\n\
3416 +No code files changed in this follow-up.\n\
3417 <!-- /agent:exchange -->\n";
3418 let changes = classify_prompt_bearing_changes(diff);
3419
3420 assert_eq!(
3421 changes.len(),
3422 1,
3423 "recovery evidence must stay grouped: {changes:?}"
3424 );
3425 assert_eq!(changes[0].kind, PromptBearingChangeKind::RecoveryArtifact);
3426 assert!(
3427 !changes
3428 .iter()
3429 .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3430 "prompt-prefixed recovery evidence must not become prompt targets: {changes:?}"
3431 );
3432 }
3433
3434 #[test]
3435 fn classify_prompt_bearing_changes_marks_inline_correction_as_content_edit() {
3436 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3437 The service returned 401 from this endpoint\n\
3438 +The service returned 503 from this endpoint\n\
3439 The rest of the response stays the same\n";
3440 let changes = classify_prompt_bearing_changes(diff);
3441 assert_eq!(changes.len(), 1);
3442 assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3443 assert_eq!(
3444 changes[0].text,
3445 "The service returned 503 from this endpoint"
3446 );
3447 }
3448
3449 #[test]
3450 fn classify_prompt_bearing_changes_marks_response_heading_as_recovery_artifact() {
3451 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
3452 ctx\n\
3453 +### Re: missed patchback — gpt-5\n\
3454 +Patched after the fact.\n\
3455 context end\n";
3456 let changes = classify_prompt_bearing_changes(diff);
3457 assert_eq!(changes.len(), 1);
3458 assert_eq!(changes[0].kind, PromptBearingChangeKind::RecoveryArtifact);
3459 assert_eq!(
3460 changes[0].text,
3461 "### Re: missed patchback — gpt-5\nPatched after the fact."
3462 );
3463 }
3464
3465 #[test]
3469 fn classify_prompt_bearing_changes_marks_ipc_proof_diagnostic_block_as_recovery_artifact() {
3470 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,12 @@\n\
3471 ctx\n\
3472 +### Re: IPC proof diagnostic (interrupted-cycle recovery) — agent-doc\n\
3473 +\n\
3474 +```text\n\
3475 +**IPC proof issue dogfood log**\n\
3476 +This is binary-authored diagnostic content, not a user prompt, so it does not require a separate response cycle.\n\
3477 +Issue class: `ipc_proof_insufficient`\n\
3478 +ipc_proof_insufficient file=/tmp/session.md source=socket_ack_content patch_id=abc invariant=live_prompt_drift_after_preflight recovery=content_ours_snapshot_next_cycle\n\
3479 +```\n\
3480 <!-- /agent:exchange -->\n";
3481 let changes = classify_prompt_bearing_changes(diff);
3482 assert!(
3483 !changes
3484 .iter()
3485 .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3486 "intact IPC-proof diagnostic must not classify as a PromptTarget: {changes:?}"
3487 );
3488 }
3489
3490 #[test]
3495 fn classify_prompt_bearing_changes_marks_separated_ipc_proof_line_as_recovery_artifact() {
3496 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
3497 ctx\n\
3498 +ipc_proof_insufficient file=/tmp/session.md source=socket_ack_content patch_id=abc invariant=live_prompt_drift_after_preflight recovery=content_ours_snapshot_next_cycle\n\
3499 <!-- /agent:exchange -->\n";
3500 let changes = classify_prompt_bearing_changes(diff);
3501 assert!(
3502 !changes
3503 .iter()
3504 .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3505 "a separated binary-authored ipc_proof_insufficient line must not be a PromptTarget: {changes:?}"
3506 );
3507 assert!(
3508 changes
3509 .iter()
3510 .any(|change| change.kind == PromptBearingChangeKind::RecoveryArtifact),
3511 "the separated diagnostic line must classify as a RecoveryArtifact: {changes:?}"
3512 );
3513 }
3514
3515 #[test]
3519 fn classify_prompt_bearing_changes_keeps_real_prompt_mentioning_ipc_as_prompt_target() {
3520 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
3521 ctx\n\
3522 +The IPC drift keeps breaking my finalize — please diagnose the root cause and fix it.\n\
3523 <!-- /agent:exchange -->\n";
3524 let changes = classify_prompt_bearing_changes(diff);
3525 assert!(
3526 changes
3527 .iter()
3528 .any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
3529 "a real user prompt that mentions ipc/drift must remain a PromptTarget: {changes:?}"
3530 );
3531 }
3532
3533 #[test]
3534 fn classify_prompt_bearing_changes_marks_boundary_only_edit_as_boundary_artifact() {
3535 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n\
3536 -### Re: Something\n\
3537 +### Re: Something (HEAD)\n";
3538 let changes = classify_prompt_bearing_changes(diff);
3539 assert_eq!(changes.len(), 1);
3540 assert_eq!(changes[0].kind, PromptBearingChangeKind::BoundaryArtifact);
3541 assert_eq!(changes[0].text, "### Re: Something (HEAD)");
3542 }
3543
3544 #[test]
3545 fn classify_prompt_bearing_changes_preserves_mixed_triage_order() {
3546 let diff = "--- snapshot\n+++ document\n@@ -1,7 +1,12 @@\n\
3547 The service returned 401 from this endpoint\n\
3548 +The service returned 503 from this endpoint\n\
3549 The rest of the response stays the same\n\
3550 +### Re: delayed patchback — gpt-5\n\
3551 +Patched after the fact.\n\
3552 Context after repair\n\
3553 +<!-- agent:boundary:test-boundary -->\n\
3554 More context\n\
3555 +Why was the `❯` prefix omitted here?\n\
3556 The response continues below\n";
3557 let changes = classify_prompt_bearing_changes(diff);
3558 assert_eq!(changes.len(), 4);
3559 assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3560 assert_eq!(
3561 changes[0].text,
3562 "The service returned 503 from this endpoint"
3563 );
3564 assert_eq!(changes[1].kind, PromptBearingChangeKind::RecoveryArtifact);
3565 assert_eq!(
3566 changes[1].text,
3567 "### Re: delayed patchback — gpt-5\nPatched after the fact."
3568 );
3569 assert_eq!(changes[2].kind, PromptBearingChangeKind::BoundaryArtifact);
3570 assert_eq!(changes[2].text, "<!-- agent:boundary:test-boundary -->");
3571 assert_eq!(changes[3].kind, PromptBearingChangeKind::PromptTarget);
3572 assert_eq!(changes[3].text, "Why was the `❯` prefix omitted here?");
3573 }
3574
3575 #[test]
3576 fn classify_prompt_bearing_changes_ignores_raw_answered_stale_exchange_tail() {
3577 let diff = concat!(
3578 "--- snapshot\n",
3579 "+++ document\n",
3580 "@@ -19,11 +19,11 @@\n",
3581 " ---\n",
3582 " \n",
3583 " ## Status\n",
3584 " \n",
3585 " <!-- agent:status patch=replace -->\n",
3586 "-Blocked on environment: GitHub is unreachable from this sandbox, so I cannot rename `ClaudeScore/BuildPartyInvestorDemo` or add the follow-up submodule here.\n",
3587 "+Updated local references for the renamed `ClaudeScore/buildparty-investor-demo` repo: `.gitmodules` now points at the new SSH URL, and the checked-out submodule's `origin` remote has been synced to match.\n",
3588 " <!-- /agent:status -->\n",
3589 " \n",
3590 " ## Exchange\n",
3591 " \n",
3592 " <!-- agent:exchange patch=append -->\n",
3593 "@@ -60,10 +60,17 @@\n",
3594 " ```bash\n",
3595 " git submodule add git@github.com:ClaudeScore/buildparty-investor-demo.git buildparty-investor-demo\n",
3596 " ```\n",
3597 " \n",
3598 " GitHub will redirect normal clone/fetch/push traffic from the old repo name, but it still recommends updating local remotes, and workflows that use the old repo as a GitHub Action reference will not redirect cleanly. Sources: [GitHub CLI `gh repo rename`](https://cli.github.com/manual/gh_repo_rename), [GitHub repo rename docs](https://docs.github.com/github/administering-a-repository/managing-repository-settings/renaming-a-repository).\n",
3599 "+I renamed the repo to ClaudeScore/buildparty-investor-demo. Please update references\n",
3600 "+I updated the repo-local references to the renamed GitHub repo.\n",
3601 "+\n",
3602 "+- `.gitmodules` now uses `git@github.com:ClaudeScore/buildparty-investor-demo.git`\n",
3603 "+- The checked-out submodule at `buildparty-investor-demo/` now has `origin` set to the same URL\n",
3604 "+\n",
3605 "+The only remaining stale reference I found is the submodule README title (`BuildPartyInvestorDemo` in `buildparty-investor-demo/README.md`). I left that untouched because it belongs to the submodule's own content rather than this parent repo's wiring.\n",
3606 " <!-- /agent:exchange -->\n",
3607 " \n",
3608 " ## Queue\n",
3609 " \n",
3610 " <!-- agent:queue -->\n",
3611 );
3612 let changes = classify_prompt_bearing_changes(diff);
3613 assert_eq!(changes.len(), 1);
3614 assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3615 assert_eq!(
3616 changes[0].text,
3617 "Updated local references for the renamed `ClaudeScore/buildparty-investor-demo` repo: `.gitmodules` now points at the new SSH URL, and the checked-out submodule's `origin` remote has been synced to match."
3618 );
3619 }
3620
3621 #[test]
3622 fn classify_prompt_bearing_changes_ignores_answered_prompt_before_blank_heading_gap() {
3623 let diff = concat!(
3624 "--- snapshot\n",
3625 "+++ document\n",
3626 "@@ -1,3 +1,8 @@\n",
3627 " <!-- agent:exchange patch=append -->\n",
3628 "-<!-- agent:boundary:initial -->\n",
3629 "+❯ do #sim1. spec-test-build-install-commit-push\n",
3630 "+\n",
3631 "+### Re: sim closeout — gpt-5 (HEAD)\n",
3632 "+\n",
3633 "+Done.\n",
3634 "+<!-- agent:boundary:new -->\n",
3635 " <!-- /agent:exchange -->\n",
3636 );
3637
3638 let changes = classify_prompt_bearing_changes(diff);
3639 assert!(
3640 changes
3641 .iter()
3642 .all(|change| change.kind != PromptBearingChangeKind::PromptTarget),
3643 "answered prompt should not remain unresolved: {changes:?}"
3644 );
3645 }
3646
3647 #[test]
3650 fn parse_slash_commands_simple() {
3651 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
3652 let cmds = parse_slash_commands(diff);
3653 assert_eq!(cmds, vec!["/clear"]);
3654 }
3655
3656 #[test]
3657 fn parse_slash_command_only_added_diff_accepts_bare_clear() {
3658 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
3659 assert_eq!(
3660 parse_slash_command_only_added_diff(diff),
3661 Some(vec!["/clear".to_string()])
3662 );
3663 }
3664
3665 #[test]
3666 fn parse_slash_command_only_added_diff_rejects_mixed_prompt_text() {
3667 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n context\n+/clear\n+Why was this answered?\n";
3668 assert_eq!(parse_slash_command_only_added_diff(diff), None);
3669 }
3670
3671 #[test]
3672 fn parse_slash_command_only_added_diff_rejects_fenced_or_blockquoted_commands() {
3673 let fenced = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
3674 assert_eq!(parse_slash_command_only_added_diff(fenced), None);
3675
3676 let quoted = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
3677 assert_eq!(parse_slash_command_only_added_diff(quoted), None);
3678 }
3679
3680 #[test]
3681 fn parse_slash_commands_trims_surrounding_whitespace() {
3682 let diff = "--- snapshot\n+++ queue\n@@ -0,0 +1,1 @@\n+ /clear \n";
3683 let cmds = parse_slash_commands(diff);
3684 assert_eq!(cmds, vec!["/clear"]);
3685 }
3686
3687 #[test]
3688 fn parse_slash_commands_with_args() {
3689 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+/agent-doc foo.md\n";
3690 let cmds = parse_slash_commands(diff);
3691 assert_eq!(cmds, vec!["/agent-doc foo.md"]);
3692 }
3693
3694 #[test]
3695 fn parse_slash_commands_ignores_fenced() {
3696 let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
3697 let cmds = parse_slash_commands(diff);
3698 assert!(cmds.is_empty());
3699 }
3700
3701 #[test]
3702 fn parse_slash_commands_ignores_blockquote() {
3703 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
3704 let cmds = parse_slash_commands(diff);
3705 assert!(cmds.is_empty());
3706 }
3707
3708 #[test]
3709 fn parse_slash_commands_ignores_context_lines() {
3710 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n /clear\n context\n";
3711 let cmds = parse_slash_commands(diff);
3712 assert!(cmds.is_empty());
3713 }
3714
3715 #[test]
3716 fn parse_slash_commands_ignores_removed_lines() {
3717 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,1 @@\n-/clear\n context\n";
3718 let cmds = parse_slash_commands(diff);
3719 assert!(cmds.is_empty());
3720 }
3721
3722 #[test]
3723 fn parse_slash_commands_requires_letter_after_slash() {
3724 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/ foo\n+//comment\n";
3726 let cmds = parse_slash_commands(diff);
3727 assert!(cmds.is_empty());
3728 }
3729
3730 #[test]
3731 fn parse_slash_commands_multiple() {
3732 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/clear\n+/agent-doc foo.md\n";
3733 let cmds = parse_slash_commands(diff);
3734 assert_eq!(cmds, vec!["/clear", "/agent-doc foo.md"]);
3735 }
3736
3737 #[test]
3738 fn parse_slash_commands_rejects_absolute_paths() {
3739 let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3743 +/home/brian/work/foo.md\n\
3744 +/tmp/scratch\n\
3745 +/usr/local/bin/agent-doc\n\
3746 +/var\n";
3747 let cmds = parse_slash_commands(diff);
3748 assert_eq!(cmds, vec!["/var"]);
3752 }
3753
3754 #[test]
3755 fn parse_slash_commands_rejects_uppercase_and_symbols() {
3756 let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3758 +/Clear\n\
3759 +/foo.bar\n\
3760 +/foo!bang\n\
3761 +/foo#hash\n";
3762 let cmds = parse_slash_commands(diff);
3763 assert!(cmds.is_empty(), "all four must be rejected; got: {cmds:?}");
3764 }
3765
3766 #[test]
3767 fn parse_slash_commands_accepts_namespaced_and_hyphenated() {
3768 let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3770 +/mcp:reload\n\
3771 +/agent-doc file.md\n\
3772 +/some_thing\n\
3773 +/v2\n";
3774 let cmds = parse_slash_commands(diff);
3775 assert_eq!(
3776 cmds,
3777 vec!["/mcp:reload", "/agent-doc file.md", "/some_thing", "/v2"]
3778 );
3779 }
3780
3781 #[test]
3782 fn detect_orchestration_request_for_synchronous_task_list() {
3783 let diff = "--- snapshot\n+++ document\n@@ -1 +1,7 @@\n ctx\n\
3784+Today is 2026-04-25. Synchronous orcestra.\n\
3785+- do #ss01. Add tests. Run benchmarks.\n\
3786+- do #ss02. Add tests. Run benchmarks.\n\
3787+- do #ss03. Add tests. Run benchmarks.\n";
3788 let request = detect_orchestration_request(diff).expect("expected orchestration request");
3789 assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
3790 assert_eq!(request.task_count, 3);
3791 assert_eq!(
3792 request.trigger_text,
3793 "Today is 2026-04-25. Synchronous orcestra."
3794 );
3795 }
3796
3797 #[test]
3798 fn detect_orchestration_request_for_parallel_batch() {
3799 let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n\
3800+Fan out these benchmark tasks.\n\
3801+- do #a1\n\
3802+- do #a2\n";
3803 let request = detect_orchestration_request(diff).expect("expected orchestration request");
3804 assert_eq!(request.mode, OrchestrationRequestMode::Parallel);
3805 assert_eq!(request.task_count, 2);
3806 }
3807
3808 #[test]
3809 fn detect_orchestration_request_requires_batch_shape() {
3810 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
3811+Run these in order.\n\
3812+- do #a1\n";
3813 assert!(
3814 detect_orchestration_request(diff).is_none(),
3815 "single-item lists should stay as ordinary work, not forced orchestration"
3816 );
3817 }
3818
3819 #[test]
3820 fn detect_orchestration_request_for_prefixed_synchronous_opera_batch() {
3821 let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
3822+❯ synchronous opera\n\
3823+❯ preset #spec-test-build-install-commit-push\n\
3824+❯ - do #jbpfx1\n\
3825+❯ - do #jbpfx2\n";
3826 let request = detect_orchestration_request(diff).expect("expected orchestration request");
3827 assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
3828 assert_eq!(request.task_count, 2);
3829 assert_eq!(
3830 request.trigger_text,
3831 "❯ synchronous opera ❯ preset #spec-test-build-install-commit-push"
3832 );
3833 }
3834
3835 #[test]
3836 fn detect_orchestration_request_ignores_prefixed_recovery_evidence() {
3837 let diff = "--- snapshot\n+++ document\n@@ -1 +1,10 @@\n ctx\n\
3838+### Re: stalled Run Agent Doc recovery — gpt-5\n\
3839+\n\
3840+I dry-ran the emitted orchestration command. It resolved only already-recorded verification evidence.\n\
3841+\n\
3842+❯ - `./gradlew test --tests TerminalUtilTest`\n\
3843+❯ - `cargo test -p agent-doc-orchestration protected_prompt_draft_preview_redacts_and_bounds_latest_draft`\n\
3844+❯ - `612b1552` `test: cover protected prompt route refusal`\n\
3845+\n\
3846+No code files changed in this follow-up.\n";
3847 assert!(
3848 detect_orchestration_request(diff).is_none(),
3849 "recovered response evidence must not emit orchestration work"
3850 );
3851 }
3852
3853 #[test]
3854 fn detect_queue_trigger_do_queue() {
3855 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue\n";
3856 assert!(detect_queue_trigger(diff));
3857 }
3858
3859 #[test]
3860 fn detect_queue_trigger_run_queue() {
3861 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+run queue\n";
3862 assert!(detect_queue_trigger(diff));
3863 }
3864
3865 #[test]
3866 fn detect_queue_trigger_case_insensitive() {
3867 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+Do Queue\n";
3868 assert!(detect_queue_trigger(diff));
3869 }
3870
3871 #[test]
3872 fn detect_queue_trigger_with_prompt_prefix() {
3873 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+❯ do queue\n";
3874 assert!(detect_queue_trigger(diff));
3875 }
3876
3877 #[test]
3878 fn detect_queue_trigger_not_pending_ref() {
3879 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do #queue Phase 2\n";
3880 assert!(!detect_queue_trigger(diff));
3881 }
3882
3883 #[test]
3884 fn detect_queue_trigger_not_in_code_fence() {
3885 let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+do queue\n+```\n";
3886 assert!(!detect_queue_trigger(diff));
3887 }
3888
3889 #[test]
3890 fn detect_queue_trigger_not_in_blockquote() {
3891 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> do queue\n";
3892 assert!(!detect_queue_trigger(diff));
3893 }
3894
3895 #[test]
3896 fn detect_queue_trigger_with_trailing_punct() {
3897 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue.\n";
3898 assert!(detect_queue_trigger(diff));
3899 }
3900
3901 #[test]
3902 fn detect_queue_trigger_not_on_context_line() {
3903 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n do queue\n+other\n";
3904 assert!(!detect_queue_trigger(diff));
3905 }
3906
3907 #[test]
3908 fn suppress_inactive_queue_additions_removes_queue_prompt_lines() {
3909 let current = concat!(
3910 "---\nqueue_active: false\n---\n\n",
3911 "<!-- agent:exchange -->\n",
3912 "### Re: prior — gpt-5\n",
3913 "Done.\n",
3914 "<!-- /agent:exchange -->\n\n",
3915 "<!-- agent:queue -->\n",
3916 "dispatch #spec-test-build-install-commit-push\n",
3917 "- do [#gdbpropscan]\n",
3918 "<!-- /agent:queue -->\n"
3919 );
3920 let diff = concat!(
3921 "--- snapshot\n",
3922 "+++ document\n",
3923 "@@ -7,5 +7,7 @@\n",
3924 " Done.\n",
3925 " <!-- /agent:exchange -->\n",
3926 " \n",
3927 " <!-- agent:queue -->\n",
3928 "+dispatch #spec-test-build-install-commit-push\n",
3929 "+- do [#gdbpropscan]\n",
3930 " <!-- /agent:queue -->\n",
3931 );
3932
3933 let filtered = suppress_inactive_queue_additions(diff, current);
3934
3935 assert!(!filtered.contains("[#gdbpropscan]"));
3936 assert!(!filtered.contains("dispatch #spec-test-build-install-commit-push"));
3937 assert!(classify_prompt_bearing_changes(&filtered).is_empty());
3938 assert!(extract_imperative_directives(&filtered).is_empty());
3939 }
3940
3941 #[test]
3942 fn detect_prompt_preset_requests_from_diff() {
3943 let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
3944+preset #1\n\
3945+presets release-check, #2\n\
3946+preset #1\n\
3947+> preset ignored\n\
3948+```md\n\
3949+preset fenced\n";
3950 assert_eq!(
3951 detect_prompt_preset_requests(diff),
3952 vec![
3953 "#1".to_string(),
3954 "release-check".to_string(),
3955 "#2".to_string()
3956 ]
3957 );
3958 }
3959
3960 #[test]
3961 fn extract_prompt_preset_requests_from_text_ignores_fences_and_blockquotes() {
3962 let text = "synchronous orchestra\npreset #1\n> preset quoted\n\n```md\npreset fenced\n```\npresets release-check and #2\n";
3963 assert_eq!(
3964 extract_prompt_preset_requests_from_text(text),
3965 vec![
3966 "#1".to_string(),
3967 "release-check".to_string(),
3968 "#2".to_string()
3969 ]
3970 );
3971 }
3972
3973 #[test]
3974 fn extract_imperative_directives_detects_do_and_build_push() {
3975 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
3976 +do #6zyp. update spec + tests. build + install for local testing. commit + push\n\
3977 +do [#dodone]. spec-test-build-install-commit-push\n\
3978 +do [#plainid]\n\
3979 +run benchmarks\n";
3980 let directives = extract_imperative_directives(diff);
3981 assert_eq!(
3982 directives,
3983 vec![
3984 "do #6zyp. update spec + tests. build + install for local testing. commit + push",
3985 "do [#dodone]. spec-test-build-install-commit-push",
3986 "do [#plainid]",
3987 "run benchmarks",
3988 ]
3989 );
3990 assert!(diff_contains_imperative_directive(diff));
3991 }
3992
3993 #[test]
3994 fn extract_imperative_directives_detects_pending_item_natural_language() {
3995 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
3996 +- [ ] [#n8q4] Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE\n";
3997 let directives = extract_imperative_directives(diff);
3998 assert_eq!(
3999 directives,
4000 vec!["Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE"]
4001 );
4002 assert!(diff_contains_imperative_directive(diff));
4003 }
4004
4005 #[test]
4006 fn extract_imperative_directives_detects_long_custom_pending_id() {
4007 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
4008 +- [ ] [#sdig2matrix] Fix the custom backlog id normalization path\n";
4009 let directives = extract_imperative_directives(diff);
4010 assert_eq!(
4011 directives,
4012 vec!["Fix the custom backlog id normalization path"]
4013 );
4014 assert!(diff_contains_imperative_directive(diff));
4015 }
4016
4017 #[test]
4018 fn extract_imperative_directives_ignores_blockquotes_and_fences() {
4019 let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
4020 +> do #skip\n\
4021 +```\n\
4022 +run tests\n\
4023 +```\n\
4024 +plain note\n";
4025 let directives = extract_imperative_directives(diff);
4026 assert!(
4027 directives.is_empty(),
4028 "unexpected directives: {directives:?}"
4029 );
4030 assert!(!diff_contains_imperative_directive(diff));
4031 }
4032
4033 #[test]
4034 fn diff_contains_imperative_directive_for_approval_word() {
4035 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+go\n";
4036 assert!(diff_contains_imperative_directive(diff));
4037 }
4038
4039 #[test]
4040 fn detect_exchange_compaction_request_matches_bare_directive() {
4041 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+compact exchange\n";
4042 assert!(detect_exchange_compaction_request(diff));
4043 }
4044
4045 #[test]
4046 fn detect_exchange_compaction_request_matches_prompt_prefixed_variant() {
4047 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+❯ compact exchange...do not add...summarize the content and delete the rest\n";
4048 assert!(detect_exchange_compaction_request(diff));
4049 }
4050
4051 #[test]
4052 fn detect_exchange_compaction_request_ignores_non_directive_mentions() {
4053 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+I failed to compact exchange earlier.\n";
4054 assert!(!detect_exchange_compaction_request(diff));
4055 }
4056
4057 #[test]
4058 fn detect_exchange_compaction_request_ignores_queue_list_item() {
4059 for diff in [
4063 "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+- Compact exchange should commit the compacted content\n",
4064 "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+* compact exchange should commit the compacted content\n",
4065 "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+1. compact exchange should commit the compacted content\n",
4066 ] {
4067 assert!(
4068 !detect_exchange_compaction_request(diff),
4069 "list-item compaction mention must not be a directive: {diff:?}"
4070 );
4071 }
4072 let prose = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+compact exchange\n";
4074 assert!(detect_exchange_compaction_request(prose));
4075 }
4076
4077 #[test]
4078 fn extract_required_response_blocks_multiple_prompts() {
4079 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,9 @@\n\
4080 ctx\n\
4081 +❯ First question?\n\
4082 +Context line.\n\
4083 +\n\
4084 +❯ Second question?\n\
4085 +do #n8q4. run tests. build + install. commit + push\n";
4086
4087 let blocks = extract_required_response_blocks(diff);
4088 assert_eq!(blocks.len(), 2);
4089 assert_eq!(blocks[0], "❯ First question?\nContext line.");
4090 assert_eq!(
4091 blocks[1],
4092 "❯ Second question?\ndo #n8q4. run tests. build + install. commit + push"
4093 );
4094 }
4095
4096 #[test]
4097 fn extract_required_response_blocks_preserves_code_fence_context() {
4098 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
4099 ctx\n\
4100 +❯ In src/boost-client, why did patchback miss the prefix?\n\
4101 +See my inquiry:\n\
4102 +```text\n\
4103 +line one\n\
4104 +line two\n\
4105 +```\n";
4106
4107 let blocks = extract_required_response_blocks(diff);
4108 assert_eq!(blocks.len(), 1);
4109 assert!(blocks[0].contains("❯ In src/boost-client"));
4110 assert!(blocks[0].contains("```text\nline one\nline two\n```"));
4111 }
4112
4113 #[test]
4114 fn format_required_response_targets_mentions_turn_completeness() {
4115 let diff =
4116 "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n+❯ Why were two prompts left unresolved?\n";
4117 let rendered = format_required_response_targets(diff).unwrap();
4118 assert!(rendered.contains("Do not stop at the newest question"));
4119 assert!(rendered.contains("<target index=\"1\">"));
4120 assert!(rendered.contains("❯ Why were two prompts left unresolved?"));
4121 }
4122
4123 #[test]
4124 fn format_prompt_bearing_changes_mentions_edit_and_artifact_contract() {
4125 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n\
4126 context before\n\
4127 +❯ Why was this missed?\n\
4128 +\n\
4129 +This line should say 503.\n\
4130 context after\n";
4131 let rendered = format_prompt_bearing_changes(diff).unwrap();
4132 assert!(rendered.contains("User-authored prompt-bearing changes (oldest first):"));
4133 assert!(rendered.contains("kind=\"prompt_target\""));
4134 assert!(rendered.contains("kind=\"content_edit\""));
4135 assert!(rendered.contains("Treat `content_edit` items as user corrections"));
4136 }
4137
4138 #[test]
4139 fn prompt_prefix_normalization_targets_preserve_prompt_context_and_skip_fences() {
4140 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
4141 ctx\n\
4142 +❯ In src/boost-client, why did patchback miss the prefix?\n\
4143 +See my inquiry:\n\
4144 +- keep this markdown bullet bare\n\
4145 + - keep nested markdown bullets bare\n\
4146 +1. keep ordered markdown bullets bare\n\
4147 +```text\n\
4148 +line one\n\
4149 +line two\n\
4150 +```\n";
4151
4152 let targets = prompt_prefix_normalization_targets(diff);
4153 assert_eq!(
4154 targets,
4155 vec!["See my inquiry:".to_string(),],
4156 "only bare prompt-context prose should need fresh prefixing"
4157 );
4158 }
4159
4160 #[test]
4161 fn first_bare_prompt_prefix_target_detects_unprefixed_prompt_block_line() {
4162 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,5 @@\n\
4163 ctx\n\
4164 +❯ Existing question?\n\
4165 +Follow-up context.\n\
4166 +### Re: answer — gpt-5\n\
4167 +Body\n";
4168
4169 let bare = first_bare_prompt_prefix_target(diff);
4170 assert_eq!(bare.as_deref(), Some("Follow-up context."));
4171 }
4172
4173 #[test]
4174 fn first_bare_prompt_prefix_target_skips_markdown_lists() {
4175 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,6 @@\n\
4176 ctx\n\
4177 +❯ Please compare these options:\n\
4178 +- option one\n\
4179 + - nested option detail\n\
4180 +1. ordered option\n\
4181 +### Re: answer — gpt-5\n";
4182
4183 let bare = first_bare_prompt_prefix_target(diff);
4184 assert_eq!(bare, None);
4185 }
4186
4187 fn pbc(kind: PromptBearingChangeKind, text: &str) -> PromptBearingChange {
4192 PromptBearingChange {
4193 kind,
4194 text: text.to_string(),
4195 }
4196 }
4197
4198 #[test]
4199 fn change_is_managed_state_only_accepts_queue_activity_toggle() {
4200 assert!(change_is_managed_state_only(&pbc(
4201 PromptBearingChangeKind::ContentEdit,
4202 "<!-- agent:queue auto -->"
4203 )));
4204 assert!(change_is_managed_state_only(&pbc(
4205 PromptBearingChangeKind::ContentEdit,
4206 "<!-- agent:queue -->"
4207 )));
4208 }
4209
4210 #[test]
4211 fn change_is_managed_state_only_accepts_frontmatter_queue_active_flip() {
4212 assert!(change_is_managed_state_only(&pbc(
4213 PromptBearingChangeKind::ContentEdit,
4214 "queue_active: true"
4215 )));
4216 assert!(change_is_managed_state_only(&pbc(
4217 PromptBearingChangeKind::ContentEdit,
4218 "queue_active: false"
4219 )));
4220 }
4221
4222 #[test]
4223 fn pipeline_only_frontmatter_write_is_no_change() {
4224 let snapshot = "---\nqueue: start\n---\n\n## Body\n- item\n";
4227 let with_pipeline = "---\nqueue: start\nagent_doc_pipeline:\n run_id: cycle-123\n step: response_captured\n turn_id: \"#x\"\n---\n\n## Body\n- item\n";
4228 assert!(
4229 unified_diff_from_contents(snapshot, with_pipeline).is_none(),
4230 "adding a pipeline block must not register as a change"
4231 );
4232 assert!(
4233 unified_diff_from_contents(with_pipeline, snapshot).is_none(),
4234 "clearing a pipeline block must not register as a change"
4235 );
4236 let with_pipeline_and_edit = "---\nqueue: start\nagent_doc_pipeline:\n run_id: cycle-123\n step: committed\n---\n\n## Body\n- item changed\n";
4238 assert!(
4239 unified_diff_from_contents(snapshot, with_pipeline_and_edit).is_some(),
4240 "a real body edit must still be detected through a pipeline write"
4241 );
4242 }
4243
4244 #[test]
4245 fn change_is_managed_state_only_accepts_pipeline_block_lines() {
4246 for line in [
4247 "agent_doc_pipeline:",
4248 " run_id: cycle-123",
4249 " step: write_applied",
4250 " turn_id: \"#x\"",
4251 " queue_task_id: \"#x\"",
4252 ] {
4253 assert!(
4254 change_is_managed_state_only(&pbc(PromptBearingChangeKind::ContentEdit, line)),
4255 "pipeline line should be managed state: {line:?}"
4256 );
4257 }
4258 }
4259
4260 #[test]
4261 fn change_is_managed_state_only_accepts_queue_item_lines() {
4262 assert!(change_is_managed_state_only(&pbc(
4263 PromptBearingChangeKind::PromptTarget,
4264 "- do [#newitem]"
4265 )));
4266 assert!(change_is_managed_state_only(&pbc(
4267 PromptBearingChangeKind::PromptTarget,
4268 "- ~do [#consumed]~"
4269 )));
4270 }
4271
4272 #[test]
4273 fn change_is_managed_state_only_accepts_backlog_and_done_item_lines() {
4274 assert!(change_is_managed_state_only(&pbc(
4275 PromptBearingChangeKind::ContentEdit,
4276 "- [ ] [#newitem] short description"
4277 )));
4278 assert!(change_is_managed_state_only(&pbc(
4279 PromptBearingChangeKind::ContentEdit,
4280 "- [/] [#gated] partial progress note"
4281 )));
4282 assert!(change_is_managed_state_only(&pbc(
4283 PromptBearingChangeKind::ContentEdit,
4284 "- 2026-05-25 [#done] closed last cycle"
4285 )));
4286 }
4287
4288 #[test]
4289 fn change_is_managed_state_only_accepts_multi_line_managed_blocks() {
4290 assert!(change_is_managed_state_only(&pbc(
4291 PromptBearingChangeKind::PromptTarget,
4292 "- do [#a]\n- do [#b]\n- do [#c]"
4293 )));
4294 }
4295
4296 #[test]
4297 fn change_is_managed_state_only_rejects_real_user_prompts() {
4298 assert!(!change_is_managed_state_only(&pbc(
4299 PromptBearingChangeKind::PromptTarget,
4300 "Why is the queue not auto-looping?"
4301 )));
4302 assert!(!change_is_managed_state_only(&pbc(
4303 PromptBearingChangeKind::PromptTarget,
4304 "❯ do this thing please"
4305 )));
4306 assert!(!change_is_managed_state_only(&pbc(
4307 PromptBearingChangeKind::ContentEdit,
4308 "Fix the regression on line 42."
4309 )));
4310 }
4311
4312 #[test]
4313 fn change_is_managed_state_only_rejects_mixed_managed_and_user_text() {
4314 assert!(!change_is_managed_state_only(&pbc(
4315 PromptBearingChangeKind::PromptTarget,
4316 "- do [#newitem]\nAnd please also fix the older bug."
4317 )));
4318 }
4319
4320 #[test]
4321 fn line_is_binary_authored_compact_summary_recognizes_summary_shapes() {
4322 for line in [
4325 "### Session Summary",
4326 "❯ ### Session Summary",
4327 "Compacted content:",
4328 "*Compacted. Content archived to `.agent-doc/archives/x.md`*",
4329 "❯ *Compacted. Content archived to `.agent-doc/archives/x.md`*",
4330 "- Archived 6 response topic(s): a; b; c; 3 more",
4331 "❯ - Archived 6 response topic(s): a; b; c; 3 more",
4332 "- Prior summary/context: prior compacted content: ...",
4333 "- Trailing prompt/context: leftover prose",
4334 ] {
4335 assert!(
4336 line_is_binary_authored_compact_summary(line),
4337 "should recognize binary-authored summary line: {line:?}"
4338 );
4339 }
4340 for line in [
4342 "Why did the compaction archive my queue items?",
4343 "do #provauth3",
4344 "- Archived the old plan, please review",
4345 "Compacted the notes manually — is that ok?",
4346 ] {
4347 assert!(
4348 !line_is_binary_authored_compact_summary(line),
4349 "must not match a real user line: {line:?}"
4350 );
4351 }
4352 }
4353
4354 #[test]
4355 fn compact_summary_replacement_is_not_a_prompt_target() {
4356 let prev = "<!-- agent:exchange patch=append -->\n\
4361### Re: old topic - gpt-5\n\nA long archived answer body.\n\n\
4362<!-- agent:boundary:old -->\n<!-- /agent:exchange -->\n";
4363 let current = "<!-- agent:exchange patch=append -->\n\
4364### Session Summary\n\n\
4365*Compacted. Content archived to `.agent-doc/archives/x.md`*\n\n\
4366Compacted content:\n\
4367- Archived 6 response topic(s): a; b; c; 3 more\n\
4368- Prior summary/context: earlier work\n\
4369<!-- agent:boundary:new -->\n<!-- /agent:exchange -->\n";
4370 let diff = unified_diff_from_contents(prev, current).expect("diff");
4371 let changes = classify_prompt_bearing_changes(&diff);
4372 assert!(
4373 changes
4374 .iter()
4375 .all(|c| c.kind != PromptBearingChangeKind::PromptTarget),
4376 "compaction summary lines must not be PromptTargets: {changes:?}"
4377 );
4378 }
4379}