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}
358
359fn is_exchange_close_marker_line(content: &str) -> bool {
360 content.trim() == "<!-- /agent:exchange -->"
361}
362
363fn parse_slash_command_candidate(line: &str) -> Option<String> {
364 let trimmed = line.trim();
365 let rest = trimmed.strip_prefix('/')?;
366 let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
367 let token = &rest[..token_end];
368 if token.is_empty() {
369 return None;
370 }
371 let mut chars = token.chars();
372 let first = chars.next()?;
373 let command_like = first.is_ascii_lowercase()
374 && chars
375 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, ':' | '_' | '-'));
376 command_like.then(|| trimmed.to_string())
377}
378
379fn line_looks_like_slash_command(line: &str) -> bool {
380 parse_slash_command_candidate(line).is_some()
381}
382
383fn line_looks_like_prompt_target(line: &str) -> bool {
384 let trimmed = line.trim();
385 let normalized_imperative = normalize_imperative_candidate(trimmed)
386 .is_some_and(|normalized| looks_like_imperative_directive(&normalized));
387 let slash_command = line_looks_like_slash_command(trimmed);
388 !trimmed.is_empty()
389 && !trimmed.starts_with("<!--")
390 && !trimmed.starts_with("```")
391 && !trimmed.starts_with("~~~")
392 && !trimmed.starts_with("### Re:")
393 && !line_has_known_response_label_after_prompt(trimmed)
394 && (slash_command
395 || trimmed.starts_with('❯')
396 || trimmed.ends_with('?')
397 || normalized_imperative)
398}
399
400pub fn text_line_looks_like_prompt_target(line: &str) -> bool {
401 line_looks_like_prompt_target(line)
402}
403
404fn block_looks_like_prompt_target(block: &str) -> bool {
405 block.lines().any(line_looks_like_prompt_target)
406}
407
408fn is_exchange_response_heading(trimmed: &str) -> bool {
409 trimmed == "## Assistant"
410 || trimmed.starts_with("### Re:")
411 || trimmed.starts_with("#### Re:")
412 || trimmed.starts_with("##### Re:")
413 || trimmed.starts_with("###### Re:")
414}
415
416fn normalized_prompt_preview_line(line: &str) -> Option<String> {
417 let trimmed = line.trim();
418 if trimmed.is_empty() || !text_line_looks_like_prompt_target(trimmed) {
419 return None;
420 }
421 Some(trimmed.trim_start_matches('❯').trim().to_string())
422}
423
424pub fn line_looks_like_fresh_prompt_after_response(trimmed: &str) -> bool {
425 if line_looks_like_plain_response_after_prompt(trimmed) {
426 return false;
427 }
428
429 let unprefixed = trimmed.trim_start_matches('❯').trim();
430 let lower = unprefixed.to_ascii_lowercase();
431 trimmed.starts_with('❯')
432 || unprefixed.ends_with('?')
433 || line_looks_like_slash_command(unprefixed)
434 || lower == "go"
435 || lower == "continue"
436 || lower.starts_with("do #")
437 || lower.starts_with("do [#")
438 || lower.starts_with("fix #")
439 || lower.starts_with("run ")
440 || lower.starts_with("rerun ")
441 || lower.starts_with("build ")
442 || lower.starts_with("test ")
443 || lower.starts_with("commit ")
444 || lower.starts_with("push ")
445 || lower.starts_with("verify ")
446 || lower.starts_with("investigate ")
447}
448
449pub(crate) fn line_looks_like_soft_prompt_request(trimmed: &str) -> bool {
450 let lower = trimmed.to_ascii_lowercase();
451 lower.starts_with("please ")
452 || lower.contains(" please ")
453 || lower.starts_with("can you ")
454 || lower.starts_with("could you ")
455 || lower.starts_with("would you ")
456 || lower.starts_with("need you to ")
457}
458
459pub fn line_looks_like_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
460 let unprefixed = trimmed
461 .strip_prefix("❯ ")
462 .or_else(|| trimmed.strip_prefix('❯'))
463 .map(str::trim_start)
464 .unwrap_or(trimmed);
465
466 if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
467 return false;
468 }
469
470 is_target
471 || line_looks_like_fresh_prompt_after_response(unprefixed)
472 || line_looks_like_soft_prompt_request(unprefixed)
473}
474
475pub fn line_looks_like_targeted_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
476 if !is_target {
477 return false;
478 }
479
480 let unprefixed = trimmed
481 .strip_prefix("❯ ")
482 .or_else(|| trimmed.strip_prefix('❯'))
483 .map(str::trim_start)
484 .unwrap_or(trimmed);
485
486 if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
487 return false;
488 }
489
490 if trimmed.starts_with('❯') || line_looks_like_soft_prompt_request(unprefixed) {
491 return true;
492 }
493
494 let lower = unprefixed.to_ascii_lowercase();
495 lower == "go"
496 || lower == "continue"
497 || line_looks_like_slash_command(unprefixed)
498 || lower.starts_with("do #")
499 || lower.starts_with("do [#")
500 || lower.starts_with("fix #")
501 || lower.starts_with("run ")
502 || lower.starts_with("rerun ")
503 || lower.starts_with("build ")
504 || lower.starts_with("test ")
505 || lower.starts_with("commit ")
506 || lower.starts_with("push ")
507 || lower.starts_with("verify ")
508 || lower.starts_with("investigate ")
509}
510
511pub fn line_looks_like_plain_response_after_prompt(trimmed: &str) -> bool {
512 if trimmed.is_empty() {
513 return false;
514 }
515
516 if line_has_known_response_label_after_prompt(trimmed) {
517 return true;
518 }
519
520 if normalized_prompt_preview_line(trimmed).is_some() {
521 return false;
522 }
523
524 if trimmed.starts_with("- ")
525 || trimmed.starts_with("* ")
526 || trimmed.starts_with("Plan:")
527 || trimmed.starts_with("Verification")
528 || trimmed.starts_with("What changed:")
529 || trimmed.starts_with("Follow-up:")
530 || trimmed.starts_with("Backlog:")
531 || trimmed.starts_with("`#")
532 {
533 return true;
534 }
535
536 let lower = trimmed.to_ascii_lowercase();
537 lower.starts_with("i updated ")
538 || lower.starts_with("i fixed ")
539 || lower.starts_with("i added ")
540 || lower.starts_with("i implemented ")
541 || lower.starts_with("i left ")
542 || lower.starts_with("updated ")
543 || lower.starts_with("fixed ")
544 || lower.starts_with("added ")
545 || lower.starts_with("implemented ")
546}
547
548fn line_has_known_response_label_after_prompt(line: &str) -> bool {
549 let Some(normalized) = normalize_response_label_candidate(line) else {
550 return false;
551 };
552 matches!(
553 normalized.as_str(),
554 s if s.starts_with("Plan:")
555 || s.starts_with("Verification:")
556 || s.starts_with("What changed:")
557 || s.starts_with("Follow-up:")
558 || s.starts_with("Commit / push:")
559 || s.starts_with("Backlog:")
560 )
561}
562
563fn normalize_response_label_candidate(line: &str) -> Option<String> {
564 let mut trimmed = line.trim();
565 if trimmed.is_empty() {
566 return None;
567 }
568
569 if let Some(rest) = trimmed.strip_prefix('❯') {
570 trimmed = rest.trim_start();
571 }
572
573 if let Some(rest) = trimmed
574 .strip_prefix("- ")
575 .or_else(|| trimmed.strip_prefix("* "))
576 .or_else(|| trimmed.strip_prefix("+ "))
577 {
578 trimmed = rest.trim_start();
579 } else {
580 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
581 if digits > 0 {
582 let rest = &trimmed[digits..];
583 if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
584 trimmed = rest.trim_start();
585 }
586 }
587 }
588
589 let stripped = strip_markdown_emphasis_pair(trimmed);
590 let normalized = stripped.trim_start();
591 (!normalized.is_empty()).then(|| normalized.to_string())
592}
593
594fn strip_markdown_emphasis_pair(text: &str) -> String {
595 for marker in ["***", "___", "**", "__", "*", "_"] {
596 if let Some(rest) = text.strip_prefix(marker)
597 && let Some(end) = rest.find(marker)
598 {
599 let label = &rest[..end];
600 let tail = &rest[end + marker.len()..];
601 if !label.trim().is_empty()
602 && (label.trim_end().ends_with(':') || tail.trim_start().starts_with(':'))
603 {
604 return format!("{}{}", label, tail);
605 }
606 }
607 }
608 text.to_string()
609}
610
611pub fn prompt_change_is_already_answered(change_text: &str) -> bool {
612 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
613 let fc = trimmed.chars().next()?;
614 if fc != '`' && fc != '~' {
615 return None;
616 }
617 let fl = trimmed.chars().take_while(|&c| c == fc).count();
618 if fl >= 3 { Some((fc, fl)) } else { None }
619 }
620
621 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
622 let fc = trimmed.chars().next().unwrap_or('\0');
623 if fc != fence_char {
624 return false;
625 }
626 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
627 fl >= fence_len && trimmed[fl..].trim().is_empty()
628 }
629
630 let mut in_fence = false;
631 let mut fence_char = '`';
632 let mut fence_len = 0usize;
633 let mut saw_prompt = false;
634 let mut saw_response = false;
635
636 for segment in change_text.split_inclusive('\n') {
637 let line = segment.trim_end_matches('\n');
638 let trimmed = line.trim();
639 if trimmed.is_empty() || trimmed.starts_with("<!--") {
640 continue;
641 }
642
643 if !in_fence {
644 if let Some((fc, fl)) = fence_open(trimmed) {
645 in_fence = true;
646 fence_char = fc;
647 fence_len = fl;
648 continue;
649 }
650 } else {
651 if fence_close(trimmed, fence_char, fence_len) {
652 in_fence = false;
653 }
654 continue;
655 }
656
657 if is_exchange_response_heading(trimmed) {
658 if saw_prompt {
659 saw_response = true;
660 }
661 continue;
662 }
663
664 if normalized_prompt_preview_line(trimmed).is_some() {
665 if saw_response && line_looks_like_fresh_prompt_after_response(trimmed) {
666 return false;
667 }
668 saw_prompt = true;
669 continue;
670 }
671
672 if !saw_prompt && line_looks_like_soft_prompt_request(trimmed) {
673 saw_prompt = true;
674 continue;
675 }
676
677 if saw_prompt && line_looks_like_plain_response_after_prompt(trimmed) {
678 saw_response = true;
679 }
680 }
681
682 saw_prompt && saw_response
683}
684
685pub fn prompt_change_is_answered_by_later_response(
686 changes: &[PromptBearingChange],
687 idx: usize,
688) -> bool {
689 if changes
690 .get(idx)
691 .is_none_or(|change| change.kind != PromptBearingChangeKind::PromptTarget)
692 {
693 return false;
694 }
695
696 for later in changes.iter().skip(idx + 1) {
697 match later.kind {
698 PromptBearingChangeKind::PromptTarget => return false,
699 PromptBearingChangeKind::RecoveryArtifact
700 | PromptBearingChangeKind::BoundaryArtifact => {
701 let heading = later
702 .text
703 .lines()
704 .find(|line| !line.trim().is_empty())
705 .unwrap_or(later.text.as_str())
706 .trim();
707 if is_exchange_response_heading(heading) {
708 return true;
709 }
710 }
711 PromptBearingChangeKind::ContentEdit => {}
712 }
713 }
714
715 false
716}
717
718fn suppress_answered_prompt_runs(changes: Vec<PromptBearingChange>) -> Vec<PromptBearingChange> {
719 let mut filtered = Vec::with_capacity(changes.len());
720 let mut skip_answered_response_run = false;
721
722 for (idx, change) in changes.iter().enumerate() {
723 match change.kind {
724 PromptBearingChangeKind::RecoveryArtifact
725 | PromptBearingChangeKind::BoundaryArtifact => {
726 filtered.push(change.clone());
727 }
728 PromptBearingChangeKind::PromptTarget => {
729 if skip_answered_response_run {
730 let preview = change
731 .text
732 .lines()
733 .find(|line| !line.trim().is_empty())
734 .unwrap_or(change.text.as_str())
735 .trim();
736 if !line_looks_like_fresh_prompt_after_response(preview) {
737 continue;
738 }
739 }
740 if prompt_change_is_already_answered(&change.text)
741 || prompt_change_is_answered_by_later_response(&changes, idx)
742 {
743 skip_answered_response_run = true;
744 continue;
745 }
746 filtered.push(change.clone());
747 }
748 PromptBearingChangeKind::ContentEdit => {
749 if skip_answered_response_run {
750 continue;
751 }
752 filtered.push(change.clone());
753 }
754 }
755 }
756
757 filtered
758}
759
760fn classify_prompt_bearing_block(
761 block_text: &str,
762 has_substantive_agent_after: bool,
763 closes_exchange_tail: bool,
764) -> Option<PromptBearingChangeKind> {
765 let trimmed = block_text.trim();
766 if trimmed.is_empty() {
767 return None;
768 }
769 let non_blank: Vec<&str> = trimmed
770 .lines()
771 .map(str::trim)
772 .filter(|line| !line.is_empty())
773 .collect();
774 if non_blank.is_empty() {
775 return None;
776 }
777 if non_blank.iter().all(|line| is_boundary_artifact_line(line)) {
778 return Some(PromptBearingChangeKind::BoundaryArtifact);
779 }
780 if non_blank
781 .first()
782 .is_some_and(|line| is_recovery_artifact_line(line))
783 {
784 return Some(PromptBearingChangeKind::RecoveryArtifact);
785 }
786 if block_looks_like_prompt_target(trimmed) {
787 return Some(PromptBearingChangeKind::PromptTarget);
788 }
789 if closes_exchange_tail
790 && non_blank
791 .iter()
792 .all(|line| line_looks_like_plain_response_after_prompt(line))
793 {
794 return None;
795 }
796 if closes_exchange_tail {
797 return Some(PromptBearingChangeKind::PromptTarget);
798 }
799 if has_substantive_agent_after {
800 return Some(PromptBearingChangeKind::ContentEdit);
801 }
802 None
803}
804
805pub fn prompt_prefix_normalization_targets(diff: &str) -> Vec<String> {
811 let mut seen = std::collections::HashSet::<String>::new();
812 let mut lines = Vec::new();
813 for change in classify_prompt_bearing_changes_raw(diff) {
814 if change.kind != PromptBearingChangeKind::PromptTarget {
815 continue;
816 }
817 for line in prompt_prefix_lines_from_block(&change.text) {
818 if line.prefixed {
819 continue;
820 }
821 if seen.insert(line.raw.clone()) {
822 lines.push(line.raw);
823 }
824 }
825 }
826 lines
827}
828
829pub fn first_bare_prompt_prefix_target(diff: &str) -> Option<String> {
831 for change in classify_prompt_bearing_changes_raw(diff) {
832 if change.kind != PromptBearingChangeKind::PromptTarget {
833 continue;
834 }
835 for line in prompt_prefix_lines_from_block(&change.text) {
836 if !line.prefixed {
837 return Some(line.raw);
838 }
839 }
840 }
841 None
842}
843
844fn classify_prompt_bearing_changes_from_annotated(
845 annotated_diff: &str,
846) -> Vec<PromptBearingChange> {
847 classify_prompt_bearing_changes_from_annotated_internal(annotated_diff, true)
848}
849
850fn classify_prompt_bearing_changes_from_annotated_internal(
851 annotated_diff: &str,
852 promote_exchange_tail_prompts: bool,
853) -> Vec<PromptBearingChange> {
854 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
855 let fc = trimmed.chars().next()?;
856 if fc != '`' && fc != '~' {
857 return None;
858 }
859 let fl = trimmed.chars().take_while(|&c| c == fc).count();
860 if fl >= 3 { Some((fc, fl)) } else { None }
861 }
862
863 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
864 let fc = trimmed.chars().next().unwrap_or('\0');
865 if fc != fence_char {
866 return false;
867 }
868 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
869 fl >= fence_len && trimmed[fl..].trim().is_empty()
870 }
871
872 let lines: Vec<&str> = annotated_diff.lines().collect();
873 let mut changes = Vec::new();
874 let mut i = 0usize;
875
876 while i < lines.len() {
877 let Some(mut block) = lines[i]
878 .strip_prefix("[user+] ")
879 .or_else(|| lines[i].strip_prefix("[user~] "))
880 .map(|line| vec![line.to_string()])
881 else {
882 i += 1;
883 continue;
884 };
885
886 let mut in_fence = false;
887 let mut fence_char = '`';
888 let mut fence_len = 3usize;
889 let first_trimmed = block[0].trim();
890 if let Some((fc, fl)) = fence_open(first_trimmed) {
891 in_fence = true;
892 fence_char = fc;
893 fence_len = fl;
894 }
895
896 i += 1;
897 while i < lines.len() {
898 if let Some(content) = lines[i]
899 .strip_prefix("[user+] ")
900 .or_else(|| lines[i].strip_prefix("[user~] "))
901 {
902 let trimmed = content.trim();
903 let starts_new_block = !in_fence
904 && block.last().is_some_and(|line| line.trim().is_empty())
905 && !trimmed.is_empty()
906 && !trimmed.starts_with("```")
907 && !trimmed.starts_with("~~~");
908 if starts_new_block {
909 break;
910 }
911 block.push(content.to_string());
912 if !in_fence {
913 if let Some((fc, fl)) = fence_open(trimmed) {
914 in_fence = true;
915 fence_char = fc;
916 fence_len = fl;
917 }
918 } else if fence_close(trimmed, fence_char, fence_len) {
919 in_fence = false;
920 }
921 i += 1;
922 } else {
923 break;
924 }
925 }
926
927 while block.first().is_some_and(|line| line.trim().is_empty()) {
928 block.remove(0);
929 }
930 while block.last().is_some_and(|line| line.trim().is_empty()) {
931 block.pop();
932 }
933 if block.is_empty() {
934 continue;
935 }
936
937 let has_substantive_agent_after = lines[i..]
938 .iter()
939 .any(|line| is_substantive_agent_line(line));
940 let closes_exchange_tail = lines[i..]
941 .iter()
942 .find_map(|line| {
943 let trimmed = line.trim();
944 if trimmed.is_empty() {
945 return None;
946 }
947 line.strip_prefix("[agent] ")
948 })
949 .is_some_and(is_exchange_close_marker_line);
950 let text = block.join("\n");
951 let Some(kind) = classify_prompt_bearing_block(
952 &text,
953 has_substantive_agent_after,
954 promote_exchange_tail_prompts && closes_exchange_tail,
955 ) else {
956 continue;
957 };
958 changes.push(PromptBearingChange { kind, text });
959 }
960
961 changes
962}
963
964fn classify_prompt_bearing_changes_raw(diff: &str) -> Vec<PromptBearingChange> {
965 let mut changes = annotate_diff(diff)
966 .map(|annotated| classify_prompt_bearing_changes_from_annotated(&annotated))
967 .unwrap_or_default();
968
969 for text in extract_prompt_target_blocks(diff) {
974 if changes.iter().any(|existing| {
975 existing.kind == PromptBearingChangeKind::PromptTarget
976 && (existing.text == text
977 || ((existing.text.contains(&text) || text.contains(&existing.text))
978 && prompt_change_is_already_answered(&existing.text)))
979 }) {
980 continue;
981 }
982 changes.push(PromptBearingChange {
983 kind: PromptBearingChangeKind::PromptTarget,
984 text,
985 });
986 }
987
988 changes
989}
990
991pub fn classify_prompt_bearing_changes(diff: &str) -> Vec<PromptBearingChange> {
992 suppress_answered_prompt_runs(classify_prompt_bearing_changes_raw(diff))
993}
994
995pub fn change_is_managed_state_only(change: &PromptBearingChange) -> bool {
1001 text_is_managed_state_only(&change.text)
1002}
1003
1004fn text_is_managed_state_only(text: &str) -> bool {
1005 let trimmed = text.trim();
1006 if trimmed.is_empty() {
1007 return false;
1008 }
1009 let lines: Vec<&str> = trimmed.lines().collect();
1010 if lines.is_empty() {
1011 return false;
1012 }
1013 lines.iter().all(|line| line_is_managed_state_only(line))
1014}
1015
1016fn line_is_managed_state_only(line: &str) -> bool {
1017 let trimmed = line.trim();
1018 if trimmed.is_empty() {
1019 return true;
1020 }
1021 if trimmed.starts_with("<!-- agent:queue")
1023 || trimmed.starts_with("<!-- /agent:queue")
1024 || trimmed.starts_with("<!-- agent:backlog")
1025 || trimmed.starts_with("<!-- /agent:backlog")
1026 || trimmed.starts_with("<!-- agent:done")
1027 || trimmed.starts_with("<!-- /agent:done")
1028 || trimmed.starts_with("<!-- agent:review")
1029 || trimmed.starts_with("<!-- /agent:review")
1030 {
1031 return true;
1032 }
1033 if trimmed.starts_with("queue_active:") {
1035 return true;
1036 }
1037 if trimmed.starts_with("agent_doc_pipeline:")
1042 || trimmed.starts_with("run_id:")
1043 || trimmed.starts_with("step:")
1044 || trimmed.starts_with("turn_id:")
1045 || trimmed.starts_with("queue_task_id:")
1046 {
1047 return true;
1048 }
1049 if trimmed.starts_with("- do ") || trimmed.starts_with("- ~do ") {
1051 return true;
1052 }
1053 if trimmed.starts_with("- [ ]")
1055 || trimmed.starts_with("- [/]")
1056 || trimmed.starts_with("- [x]")
1057 || trimmed.starts_with("- [-]")
1058 || trimmed.starts_with("- [?]")
1059 {
1060 return true;
1061 }
1062 if trimmed.len() >= 12
1064 && trimmed.starts_with("- ")
1065 && trimmed.chars().nth(2).is_some_and(|c| c.is_ascii_digit())
1066 {
1067 return true;
1068 }
1069 if trimmed.starts_with("preset #") || trimmed.starts_with("preset:") {
1071 return true;
1072 }
1073 false
1074}
1075
1076pub fn suppress_inactive_queue_additions(diff: &str, current_content: &str) -> String {
1083 let ranges = queue_line_ranges(current_content);
1084 if ranges.is_empty() {
1085 return diff.to_string();
1086 }
1087
1088 let mut current_line: Option<usize> = None;
1089 let mut filtered = String::new();
1090 for line in diff.lines() {
1091 if line.starts_with("@@ ") {
1092 current_line = hunk_current_start_line(line);
1093 filtered.push_str(line);
1094 filtered.push('\n');
1095 continue;
1096 }
1097
1098 if line.starts_with("--- ") || line.starts_with("+++ ") {
1099 filtered.push_str(line);
1100 filtered.push('\n');
1101 continue;
1102 }
1103
1104 if line.starts_with('+') && !line.starts_with("+++") {
1105 let line_no = current_line.unwrap_or(0);
1106 if current_line_is_in_ranges(line_no, &ranges) {
1107 current_line = current_line.map(|n| n + 1);
1108 continue;
1109 }
1110 current_line = current_line.map(|n| n + 1);
1111 filtered.push_str(line);
1112 filtered.push('\n');
1113 continue;
1114 }
1115
1116 if line.starts_with(' ') {
1117 current_line = current_line.map(|n| n + 1);
1118 }
1119
1120 filtered.push_str(line);
1121 filtered.push('\n');
1122 }
1123
1124 filtered
1125}
1126
1127fn queue_line_ranges(content: &str) -> Vec<(usize, usize)> {
1128 let Ok(components) = component::parse(content) else {
1129 return Vec::new();
1130 };
1131 components
1132 .iter()
1133 .filter(|component| component.name == "queue")
1134 .map(|component| {
1135 (
1136 line_number_at_byte(content, component.open_start),
1137 line_number_at_byte(content, component.close_end.saturating_sub(1)),
1138 )
1139 })
1140 .collect()
1141}
1142
1143fn line_number_at_byte(content: &str, byte_offset: usize) -> usize {
1144 let end = byte_offset.min(content.len());
1145 content[..end].bytes().filter(|b| *b == b'\n').count() + 1
1146}
1147
1148fn hunk_current_start_line(header: &str) -> Option<usize> {
1149 let plus = header
1150 .split_whitespace()
1151 .find(|part| part.starts_with('+'))?;
1152 let number = plus
1153 .trim_start_matches('+')
1154 .split(',')
1155 .next()
1156 .unwrap_or_default();
1157 number.parse::<usize>().ok()
1158}
1159
1160fn current_line_is_in_ranges(line_no: usize, ranges: &[(usize, usize)]) -> bool {
1161 ranges
1162 .iter()
1163 .any(|(start, end)| line_no >= *start && line_no <= *end)
1164}
1165
1166fn prompt_prefix_lines_from_block(block: &str) -> Vec<PromptPrefixLine> {
1167 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
1168 let fc = trimmed.chars().next()?;
1169 if fc != '`' && fc != '~' {
1170 return None;
1171 }
1172 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1173 if fl >= 3 { Some((fc, fl)) } else { None }
1174 }
1175
1176 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
1177 let fc = trimmed.chars().next().unwrap_or('\0');
1178 if fc != fence_char {
1179 return false;
1180 }
1181 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1182 fl >= fence_len && trimmed[fl..].trim().is_empty()
1183 }
1184
1185 let mut lines = Vec::new();
1186 let mut in_fence = false;
1187 let mut fence_char = '`';
1188 let mut fence_len = 3usize;
1189 let mut in_response_block = false;
1190
1191 for line in block.lines() {
1192 let trimmed = line.trim();
1193 if !in_fence {
1194 if let Some((fc, fl)) = fence_open(trimmed) {
1195 in_fence = true;
1196 fence_char = fc;
1197 fence_len = fl;
1198 continue;
1199 }
1200 } else if fence_close(trimmed, fence_char, fence_len) {
1201 in_fence = false;
1202 continue;
1203 }
1204
1205 if in_fence || trimmed.is_empty() || trimmed.starts_with("<!--") {
1206 continue;
1207 }
1208
1209 if line_looks_like_markdown_list_item(trimmed) {
1210 continue;
1211 }
1212
1213 if is_exchange_response_heading(trimmed) {
1214 in_response_block = true;
1215 continue;
1216 }
1217
1218 if in_response_block {
1219 if line_looks_like_targeted_prompt_prefix_repair_start(trimmed, true) {
1220 in_response_block = false;
1221 } else {
1222 continue;
1223 }
1224 }
1225
1226 lines.push(PromptPrefixLine {
1227 raw: line.to_string(),
1228 prefixed: trimmed.starts_with('❯'),
1229 });
1230 }
1231
1232 lines
1233}
1234
1235fn extract_prompt_target_blocks(diff: &str) -> Vec<String> {
1236 fn is_response_heading(line: &str) -> bool {
1237 line.starts_with("### Re:") || line.starts_with("#### Re:") || line.starts_with("## Re:")
1238 }
1239
1240 fn fence_open(trimmed: &str) -> Option<(char, usize)> {
1241 let fc = trimmed.chars().next()?;
1242 if fc != '`' && fc != '~' {
1243 return None;
1244 }
1245 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1246 if fl >= 3 { Some((fc, fl)) } else { None }
1247 }
1248
1249 fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
1250 let fc = trimmed.chars().next().unwrap_or('\0');
1251 if fc != fence_char {
1252 return false;
1253 }
1254 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1255 fl >= fence_len && trimmed[fl..].trim().is_empty()
1256 }
1257
1258 fn trim_block_lines(lines: &mut Vec<String>) {
1259 while lines.first().is_some_and(|line| line.trim().is_empty()) {
1260 lines.remove(0);
1261 }
1262 while lines.last().is_some_and(|line| line.trim().is_empty()) {
1263 lines.pop();
1264 }
1265 }
1266
1267 fn block_start(line: &str) -> bool {
1268 let trimmed = line.trim();
1269 if trimmed.is_empty()
1270 || trimmed.starts_with("<!--")
1271 || trimmed.starts_with("```")
1272 || trimmed.starts_with("~~~")
1273 || is_response_heading(trimmed)
1274 {
1275 return false;
1276 }
1277 line_looks_like_prompt_target(trimmed)
1278 }
1279
1280 fn flush(blocks: &mut Vec<String>, current: &mut Vec<String>) {
1281 trim_block_lines(current);
1282 if !current.is_empty() {
1283 blocks.push(current.join("\n"));
1284 current.clear();
1285 }
1286 }
1287
1288 let mut blocks = Vec::new();
1289 let mut current: Vec<String> = Vec::new();
1290 let mut in_fence = false;
1291 let mut fence_char = '`';
1292 let mut fence_len = 3usize;
1293
1294 for line in diff.lines() {
1295 if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
1296 if !in_fence {
1297 flush(&mut blocks, &mut current);
1298 }
1299 continue;
1300 }
1301
1302 let Some(content) = line.strip_prefix('+') else {
1303 if !in_fence {
1304 flush(&mut blocks, &mut current);
1305 }
1306 continue;
1307 };
1308
1309 let trimmed = content.trim();
1310 let starts_new_block = !current.is_empty()
1311 && !in_fence
1312 && ((current.last().is_some_and(|line| line.trim().is_empty()) && !trimmed.is_empty())
1313 || trimmed.starts_with('❯'));
1314 if starts_new_block && current.iter().any(|line| !line.trim().is_empty()) {
1315 flush(&mut blocks, &mut current);
1316 }
1317
1318 if current.is_empty() && !block_start(content) {
1319 continue;
1320 }
1321
1322 current.push(content.to_string());
1323
1324 if !in_fence {
1325 if let Some((fc, fl)) = fence_open(trimmed) {
1326 in_fence = true;
1327 fence_char = fc;
1328 fence_len = fl;
1329 }
1330 } else if fence_close(trimmed, fence_char, fence_len) {
1331 in_fence = false;
1332 }
1333 }
1334
1335 flush(&mut blocks, &mut current);
1336 blocks
1337}
1338
1339fn flush_removes(pending: &mut Vec<String>, result: &mut Vec<String>) {
1341 for removed in pending.drain(..) {
1342 result.push(format!("[user-] {}", removed));
1343 }
1344}
1345
1346fn common_prefix_len(a: &str, b: &str) -> usize {
1348 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
1349}
1350
1351const BUILTIN_COMMAND_NAMES: &[&str] = &[
1354 "/help",
1355 "/model",
1356 "/clear",
1357 "/compact",
1358 "/cost",
1359 "/login",
1360 "/logout",
1361 "/status",
1362 "/config",
1363 "/memory",
1364 "/review",
1365 "/bug",
1366 "/fast",
1367 "/slow",
1368 "/permissions",
1369 "/terminal-setup",
1370 "/doctor",
1371 "/init",
1372 "/pr-comments",
1373 "/vim",
1374 "/diff",
1375 "/undo",
1376 "/resume",
1377 "/listen",
1378 "/mcp",
1379 "/approved-tools",
1380 "/add-dir",
1381 "/release-notes",
1382 "/hooks",
1383 "/btw",
1384];
1385
1386pub fn is_builtin_command(cmd: &str) -> bool {
1388 let cmd_name = cmd.split_whitespace().next().unwrap_or("");
1389 BUILTIN_COMMAND_NAMES.contains(&cmd_name)
1390}
1391
1392pub struct ParsedSlashCommands {
1394 pub skill_commands: Vec<String>,
1396 pub builtin_commands: Vec<String>,
1398}
1399
1400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1402#[serde(rename_all = "snake_case")]
1403pub struct OrchestrationRequest {
1404 pub mode: OrchestrationRequestMode,
1406 pub trigger_text: String,
1408 pub task_count: usize,
1410}
1411
1412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1414#[serde(rename_all = "snake_case")]
1415pub enum OrchestrationRequestMode {
1416 Sequential,
1417 Parallel,
1418 Dag,
1419}
1420
1421pub fn detect_prompt_preset_requests(diff: &str) -> Vec<String> {
1423 let mut requests = Vec::new();
1424 let mut in_fence = false;
1425 let mut fence_char = '`';
1426 let mut fence_len = 0usize;
1427
1428 for line in diff.lines() {
1429 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1430 continue;
1431 }
1432
1433 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1434 &line[1..]
1435 } else {
1436 line
1437 };
1438 let trimmed = content.trim_start();
1439
1440 if !in_fence {
1441 let fc = trimmed.chars().next().unwrap_or('\0');
1442 if fc == '`' || fc == '~' {
1443 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1444 if fl >= 3 {
1445 in_fence = true;
1446 fence_char = fc;
1447 fence_len = fl;
1448 continue;
1449 }
1450 }
1451 } else {
1452 let fc = trimmed.chars().next().unwrap_or('\0');
1453 if fc == fence_char {
1454 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1455 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1456 in_fence = false;
1457 }
1458 }
1459 continue;
1460 }
1461
1462 if !line.starts_with('+') || line.starts_with("+++") {
1463 continue;
1464 }
1465 if content.starts_with('>') {
1466 continue;
1467 }
1468
1469 collect_prompt_preset_requests_from_line(content, &mut requests);
1470 }
1471
1472 requests
1473}
1474
1475pub fn extract_prompt_preset_requests_from_text(text: &str) -> Vec<String> {
1477 let mut requests = Vec::new();
1478 let mut in_fence = false;
1479 let mut fence_char = '`';
1480 let mut fence_len = 0usize;
1481
1482 for line in text.lines() {
1483 let trimmed = line.trim_start();
1484
1485 if !in_fence {
1486 let fc = trimmed.chars().next().unwrap_or('\0');
1487 if fc == '`' || fc == '~' {
1488 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1489 if fl >= 3 {
1490 in_fence = true;
1491 fence_char = fc;
1492 fence_len = fl;
1493 continue;
1494 }
1495 }
1496 } else {
1497 let fc = trimmed.chars().next().unwrap_or('\0');
1498 if fc == fence_char {
1499 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1500 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1501 in_fence = false;
1502 }
1503 }
1504 continue;
1505 }
1506
1507 if trimmed.starts_with('>') {
1508 continue;
1509 }
1510
1511 collect_prompt_preset_requests_from_line(line, &mut requests);
1512 }
1513
1514 requests
1515}
1516
1517fn collect_prompt_preset_requests_from_line(line: &str, requests: &mut Vec<String>) {
1518 let Some(names) = parse_prompt_preset_directive(line) else {
1519 return;
1520 };
1521 for name in names {
1522 if !requests.iter().any(|existing| existing == &name) {
1523 requests.push(name);
1524 }
1525 }
1526}
1527
1528fn parse_prompt_preset_directive(line: &str) -> Option<Vec<String>> {
1529 let mut trimmed = line.trim();
1530 if trimmed.is_empty() {
1531 return None;
1532 }
1533
1534 if let Some(rest) = trimmed.strip_prefix('❯') {
1535 trimmed = rest.trim_start();
1536 }
1537
1538 if let Some(rest) = trimmed
1539 .strip_prefix("- ")
1540 .or_else(|| trimmed.strip_prefix("* "))
1541 .or_else(|| trimmed.strip_prefix("+ "))
1542 {
1543 trimmed = rest.trim_start();
1544 } else {
1545 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
1546 if digits > 0 {
1547 let rest = &trimmed[digits..];
1548 if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
1549 trimmed = rest.trim_start();
1550 }
1551 }
1552 }
1553
1554 let lower = trimmed.to_ascii_lowercase();
1555 let rest = if lower.starts_with("presets ") {
1556 &trimmed["presets ".len()..]
1557 } else if lower.starts_with("preset ") {
1558 &trimmed["preset ".len()..]
1559 } else {
1560 return None;
1561 };
1562
1563 let rest = rest.trim();
1564 if rest.is_empty() {
1565 return None;
1566 }
1567
1568 let mut names = Vec::new();
1569 for segment in rest.split(',') {
1570 let segment = segment.trim();
1571 if segment.is_empty() {
1572 continue;
1573 }
1574 for part in segment.split(" and ") {
1575 let name = part.trim().trim_end_matches(['.', ':', ';']).trim();
1576 if !name.is_empty() {
1577 names.push(name.to_string());
1578 }
1579 }
1580 }
1581
1582 (!names.is_empty()).then_some(names)
1583}
1584
1585pub fn parse_slash_commands_classified(diff: &str) -> ParsedSlashCommands {
1587 let commands = parse_slash_commands(diff);
1588 let mut skill_commands = Vec::new();
1589 let mut builtin_commands = Vec::new();
1590 for cmd in commands {
1591 if is_builtin_command(&cmd) {
1592 builtin_commands.push(cmd);
1593 } else {
1594 skill_commands.push(cmd);
1595 }
1596 }
1597 ParsedSlashCommands {
1598 skill_commands,
1599 builtin_commands,
1600 }
1601}
1602
1603pub fn parse_slash_command_only_added_diff(diff: &str) -> Option<Vec<String>> {
1607 let mut commands = Vec::new();
1608 let mut in_fence = false;
1609 let mut fence_char = '`';
1610 let mut fence_len = 0usize;
1611
1612 for line in diff.lines() {
1613 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1614 continue;
1615 }
1616
1617 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1618 &line[1..]
1619 } else {
1620 line
1621 };
1622 let trimmed = content.trim_start();
1623 let was_in_fence = in_fence;
1624 let mut fence_delimiter = false;
1625
1626 if !in_fence {
1627 let fc = trimmed.chars().next().unwrap_or('\0');
1628 if fc == '`' || fc == '~' {
1629 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1630 if fl >= 3 {
1631 in_fence = true;
1632 fence_char = fc;
1633 fence_len = fl;
1634 fence_delimiter = true;
1635 }
1636 }
1637 } else {
1638 let fc = trimmed.chars().next().unwrap_or('\0');
1639 if fc == fence_char {
1640 let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
1641 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1642 in_fence = false;
1643 fence_delimiter = true;
1644 }
1645 }
1646 }
1647
1648 if !line.starts_with('+') || line.starts_with("+++") {
1649 continue;
1650 }
1651
1652 if content.trim().is_empty() {
1653 continue;
1654 }
1655 if was_in_fence || fence_delimiter || trimmed.starts_with('>') {
1656 return None;
1657 }
1658 let command = parse_slash_command_candidate(content)?;
1659 commands.push(command);
1660 }
1661
1662 (!commands.is_empty()).then_some(commands)
1663}
1664
1665pub fn parse_slash_commands(diff: &str) -> Vec<String> {
1675 let mut commands = Vec::new();
1676 let mut in_fence = false;
1677 let mut fence_char = '`';
1678 let mut fence_len = 0usize;
1679
1680 for line in diff.lines() {
1681 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1683 continue;
1684 }
1685
1686 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1688 &line[1..]
1689 } else {
1690 line
1691 };
1692
1693 let trimmed = content.trim_start();
1695 if !in_fence {
1696 let fc = trimmed.chars().next().unwrap_or('\0');
1697 if fc == '`' || fc == '~' {
1698 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1699 if fl >= 3 {
1700 in_fence = true;
1701 fence_char = fc;
1702 fence_len = fl;
1703 continue; }
1705 }
1706 } else {
1707 let fc = trimmed.chars().next().unwrap_or('\0');
1709 if fc == fence_char {
1710 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1711 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1712 in_fence = false;
1713 continue; }
1715 }
1716 }
1717
1718 if !line.starts_with('+') || line.starts_with("+++") {
1720 continue;
1721 }
1722
1723 if in_fence {
1725 continue;
1726 }
1727
1728 if content.trim_start().starts_with('>') {
1730 continue;
1731 }
1732
1733 if let Some(command) = parse_slash_command_candidate(content) {
1738 commands.push(command);
1739 }
1740 }
1741
1742 commands
1743}
1744
1745pub fn detect_orchestration_request(diff: &str) -> Option<OrchestrationRequest> {
1752 for block in collect_added_text_blocks(diff) {
1753 let task_count = block
1754 .iter()
1755 .filter(|line| parse_markdown_list_item(line).is_some())
1756 .count();
1757 if task_count < 2 {
1758 continue;
1759 }
1760
1761 let trigger_lines: Vec<&str> = block
1762 .iter()
1763 .map(|line| line.trim())
1764 .filter(|line| !line.is_empty() && parse_markdown_list_item(line).is_none())
1765 .collect();
1766 let trigger_text = if trigger_lines.is_empty() {
1767 block.join(" ")
1768 } else {
1769 trigger_lines.join(" ")
1770 };
1771 let Some(mode) = detect_orchestration_mode(&trigger_text) else {
1772 continue;
1773 };
1774
1775 return Some(OrchestrationRequest {
1776 mode,
1777 trigger_text,
1778 task_count,
1779 });
1780 }
1781
1782 None
1783}
1784
1785pub fn detect_queue_trigger(diff: &str) -> bool {
1791 let mut in_fence = false;
1792 let mut fence_char = '`';
1793 let mut fence_len = 0usize;
1794
1795 for line in diff.lines() {
1796 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1797 continue;
1798 }
1799 if !line.starts_with('+') {
1800 continue;
1801 }
1802 let content = &line[1..];
1803 let trimmed = content.trim_start();
1804
1805 if !in_fence {
1807 let fc = trimmed.chars().next().unwrap_or('\0');
1808 if fc == '`' || fc == '~' {
1809 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1810 if fl >= 3 {
1811 in_fence = true;
1812 fence_char = fc;
1813 fence_len = fl;
1814 continue;
1815 }
1816 }
1817 } else {
1818 let fc = trimmed.chars().next().unwrap_or('\0');
1819 if fc == fence_char {
1820 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1821 if fl >= fence_len {
1822 in_fence = false;
1823 }
1824 }
1825 continue;
1826 }
1827
1828 if trimmed.starts_with('>') {
1830 continue;
1831 }
1832
1833 let text = trimmed.strip_prefix("❯ ").unwrap_or(trimmed);
1835 let lower = text.to_lowercase();
1836
1837 if lower.starts_with("do queue") || lower.starts_with("run queue") {
1838 let after = if lower.starts_with("do queue") {
1839 &text[8..]
1840 } else {
1841 &text[9..]
1842 };
1843 if after.is_empty() || after.starts_with(|c: char| !c.is_alphanumeric() && c != '#') {
1845 return true;
1846 }
1847 }
1848 }
1849 false
1850}
1851
1852pub fn unified_diff_from_contents(previous: &str, current: &str) -> Option<String> {
1856 let previous_stripped = strip_comments(previous);
1857 let current_stripped = strip_comments(current);
1858 let diff = TextDiff::from_lines(&previous_stripped, ¤t_stripped);
1859 let has_changes = diff.iter_all_changes().any(|c| c.tag() != ChangeTag::Equal);
1860 if !has_changes {
1861 return None;
1862 }
1863 Some(
1864 diff.unified_diff()
1865 .context_radius(5)
1866 .header("snapshot", "document")
1867 .to_string(),
1868 )
1869}
1870
1871pub fn synthetic_added_lines_diff(body: &str, target: &str) -> String {
1876 let lines = body.lines().collect::<Vec<_>>();
1877 let count = lines.len().max(1);
1878 let mut diff = format!("--- snapshot\n+++ {target}\n@@ -0,0 +1,{count} @@\n");
1879 if lines.is_empty() {
1880 diff.push_str("+\n");
1881 return diff;
1882 }
1883 for line in lines {
1884 diff.push('+');
1885 diff.push_str(line);
1886 diff.push('\n');
1887 }
1888 diff
1889}
1890
1891pub fn extract_imperative_directives(diff: &str) -> Vec<String> {
1897 let mut directives = Vec::new();
1898 let mut in_fence = false;
1899 let mut fence_char = '`';
1900 let mut fence_len = 0usize;
1901
1902 for line in diff.lines() {
1903 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1904 continue;
1905 }
1906
1907 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1908 &line[1..]
1909 } else {
1910 line
1911 };
1912 let trimmed = content.trim_start();
1913
1914 if !in_fence {
1915 let fc = trimmed.chars().next().unwrap_or('\0');
1916 if fc == '`' || fc == '~' {
1917 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1918 if fl >= 3 {
1919 in_fence = true;
1920 fence_char = fc;
1921 fence_len = fl;
1922 continue;
1923 }
1924 }
1925 } else {
1926 let fc = trimmed.chars().next().unwrap_or('\0');
1927 if fc == fence_char {
1928 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1929 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
1930 in_fence = false;
1931 continue;
1932 }
1933 }
1934 }
1935
1936 if !line.starts_with('+') || line.starts_with("+++") || in_fence {
1937 continue;
1938 }
1939
1940 if content.starts_with('>') {
1941 continue;
1942 }
1943
1944 let Some(normalized) = normalize_imperative_candidate(content) else {
1945 continue;
1946 };
1947
1948 if looks_like_imperative_directive(&normalized) {
1949 directives.push(normalized);
1950 }
1951 }
1952
1953 directives
1954}
1955
1956pub fn diff_contains_imperative_directive(diff: &str) -> bool {
1959 if !extract_imperative_directives(diff).is_empty() {
1960 return true;
1961 }
1962 matches!(classify_diff(diff).diff_type, DiffType::Approval)
1963}
1964
1965pub fn detect_exchange_compaction_request(diff: &str) -> bool {
1971 let mut in_fence = false;
1972 let mut fence_char = '`';
1973 let mut fence_len = 0usize;
1974
1975 for line in diff.lines() {
1976 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
1977 continue;
1978 }
1979
1980 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
1981 &line[1..]
1982 } else {
1983 line
1984 };
1985 let trimmed = content.trim_start();
1986
1987 if !in_fence {
1988 let fc = trimmed.chars().next().unwrap_or('\0');
1989 if fc == '`' || fc == '~' {
1990 let fl = trimmed.chars().take_while(|&c| c == fc).count();
1991 if fl >= 3 {
1992 in_fence = true;
1993 fence_char = fc;
1994 fence_len = fl;
1995 continue;
1996 }
1997 }
1998 } else {
1999 let fc = trimmed.chars().next().unwrap_or('\0');
2000 if fc == fence_char {
2001 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2002 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2003 in_fence = false;
2004 continue;
2005 }
2006 }
2007 }
2008
2009 if !line.starts_with('+') || line.starts_with("+++") || in_fence || content.starts_with('>')
2010 {
2011 continue;
2012 }
2013
2014 let Some(normalized) = normalize_imperative_candidate(content) else {
2015 continue;
2016 };
2017 let lower = normalized.to_ascii_lowercase();
2018 if lower.starts_with("compact exchange") || lower.starts_with("compact the exchange") {
2019 return true;
2020 }
2021 }
2022
2023 false
2024}
2025
2026#[allow(dead_code)]
2033pub fn extract_required_response_blocks(diff: &str) -> Vec<String> {
2034 extract_prompt_target_blocks(diff)
2035}
2036
2037#[allow(dead_code)]
2039pub fn format_required_response_targets(diff: &str) -> Option<String> {
2040 let blocks = extract_required_response_blocks(diff);
2041 if blocks.is_empty() {
2042 return None;
2043 }
2044
2045 let mut out = String::from(
2046 "Required response targets (oldest first):\n\
2047 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",
2048 );
2049 for (idx, block) in blocks.iter().enumerate() {
2050 out.push_str(&format!(
2051 "<target index=\"{}\">\n{}\n</target>\n\n",
2052 idx + 1,
2053 block
2054 ));
2055 }
2056 Some(out)
2057}
2058
2059pub fn format_prompt_bearing_changes(diff: &str) -> Option<String> {
2061 let changes = classify_prompt_bearing_changes(diff);
2062 if changes.is_empty() {
2063 return None;
2064 }
2065
2066 let mut out = String::from(
2067 "User-authored prompt-bearing changes (oldest first):\n\
2068 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\
2069 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",
2070 );
2071 for (idx, change) in changes.iter().enumerate() {
2072 out.push_str(&format!(
2073 "<change index=\"{}\" kind=\"{}\">\n{}\n</change>\n\n",
2074 idx + 1,
2075 serde_json::to_string(&change.kind)
2076 .unwrap_or_else(|_| "\"prompt_target\"".to_string())
2077 .trim_matches('"'),
2078 change.text
2079 ));
2080 }
2081 Some(out)
2082}
2083
2084fn looks_like_imperative_directive(line: &str) -> bool {
2085 let lower = line.to_ascii_lowercase();
2086 let compact = lower.trim();
2087 let exact = compact.trim_end_matches(|c: char| c.is_ascii_punctuation());
2088 if APPROVAL_WORDS.contains(&exact) {
2089 return true;
2090 }
2091 compact.starts_with("do #")
2092 || compact.starts_with("do [#")
2093 || compact.starts_with("fix #")
2094 || compact.starts_with("fix this")
2095 || compact.contains(" run tests")
2096 || compact.starts_with("run tests")
2097 || compact.contains(" run benchmarks")
2098 || compact.starts_with("run benchmarks")
2099 || compact.contains(" build + install")
2100 || compact.starts_with("build + install")
2101 || compact.contains(" build and install")
2102 || compact.starts_with("build and install")
2103 || compact.contains(" commit + push")
2104 || compact.starts_with("commit + push")
2105 || compact.contains(" commit and push")
2106 || compact.starts_with("commit and push")
2107 || starts_with_imperative_verb(compact)
2108}
2109
2110fn normalize_imperative_candidate(line: &str) -> Option<String> {
2111 let mut trimmed = line.trim();
2112 if trimmed.is_empty() {
2113 return None;
2114 }
2115
2116 if let Some(rest) = trimmed.strip_prefix("- ") {
2117 trimmed = rest.trim_start();
2118 }
2119
2120 trimmed = strip_pending_checkbox_prefix(trimmed);
2121
2122 if let Some(rest) = trimmed.strip_prefix("[#")
2123 && let Some(close) = rest.find(']')
2124 {
2125 let id = &rest[..close];
2126 if crate::pending::is_valid_pending_id(id) {
2127 trimmed = rest[close + 1..].trim_start();
2128 }
2129 }
2130
2131 let normalized = trimmed
2132 .trim_start_matches('❯')
2133 .trim_start()
2134 .trim_end_matches(|c: char| c.is_ascii_punctuation() && c != ']')
2135 .trim();
2136 if normalized.is_empty() {
2137 None
2138 } else {
2139 Some(normalized.to_string())
2140 }
2141}
2142
2143fn collect_added_text_blocks(diff: &str) -> Vec<Vec<String>> {
2144 let mut blocks = Vec::new();
2145 let mut current = Vec::new();
2146 let mut in_fence = false;
2147 let mut fence_char = '`';
2148 let mut fence_len = 0usize;
2149
2150 for line in diff.lines() {
2151 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
2152 if !current.is_empty() {
2153 blocks.push(std::mem::take(&mut current));
2154 }
2155 continue;
2156 }
2157
2158 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
2159 &line[1..]
2160 } else {
2161 line
2162 };
2163 let trimmed = content.trim_start();
2164
2165 if !in_fence {
2166 let fc = trimmed.chars().next().unwrap_or('\0');
2167 if fc == '`' || fc == '~' {
2168 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2169 if fl >= 3 {
2170 in_fence = true;
2171 fence_char = fc;
2172 fence_len = fl;
2173 continue;
2174 }
2175 }
2176 } else {
2177 let fc = trimmed.chars().next().unwrap_or('\0');
2178 if fc == fence_char {
2179 let fl = trimmed.chars().take_while(|&c| c == fc).count();
2180 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
2181 in_fence = false;
2182 }
2183 }
2184 continue;
2185 }
2186
2187 if !line.starts_with('+') || line.starts_with("+++") {
2188 if !current.is_empty() {
2189 blocks.push(std::mem::take(&mut current));
2190 }
2191 continue;
2192 }
2193
2194 if content.starts_with('>') {
2195 continue;
2196 }
2197
2198 current.push(content.trim_end().to_string());
2199 }
2200
2201 if !current.is_empty() {
2202 blocks.push(current);
2203 }
2204
2205 blocks
2206}
2207
2208fn parse_markdown_list_item(line: &str) -> Option<&str> {
2209 let trimmed = strip_prompt_prefix(line.trim_start());
2210 for prefix in ["- ", "* ", "+ "] {
2211 if let Some(rest) = trimmed.strip_prefix(prefix) {
2212 let rest = rest.trim();
2213 return (!rest.is_empty()).then_some(rest);
2214 }
2215 }
2216
2217 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
2218 if digits > 0 {
2219 let rest = &trimmed[digits..];
2220 if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
2221 let rest = rest.trim();
2222 return (!rest.is_empty()).then_some(rest);
2223 }
2224 }
2225
2226 None
2227}
2228
2229pub fn line_looks_like_markdown_list_item(line: &str) -> bool {
2230 parse_markdown_list_item(line).is_some()
2231}
2232
2233fn strip_prompt_prefix(line: &str) -> &str {
2234 line.strip_prefix("❯ ")
2235 .or_else(|| line.strip_prefix('❯'))
2236 .map(str::trim_start)
2237 .unwrap_or(line)
2238}
2239
2240fn detect_orchestration_mode(text: &str) -> Option<OrchestrationRequestMode> {
2241 let lower = text.to_ascii_lowercase();
2242
2243 if contains_any(
2244 &lower,
2245 &[
2246 "dependency graph",
2247 "depends on",
2248 "blocked by",
2249 "prerequisite",
2250 "then unblock",
2251 ],
2252 ) || lower.contains("after #")
2253 {
2254 return Some(OrchestrationRequestMode::Dag);
2255 }
2256
2257 if contains_any(
2258 &lower,
2259 &[
2260 "fan out",
2261 "concurrent",
2262 "at the same time",
2263 "in parallel",
2264 "simultaneously",
2265 "parallelize",
2266 ],
2267 ) {
2268 return Some(OrchestrationRequestMode::Parallel);
2269 }
2270
2271 if contains_any(
2272 &lower,
2273 &[
2274 "orchestra",
2275 "orcestra",
2276 "orchestrate",
2277 "chain",
2278 "in order",
2279 "one by one",
2280 "sequential",
2281 "sequentially",
2282 "synchronous",
2283 "run these sequentially",
2284 ],
2285 ) {
2286 return Some(OrchestrationRequestMode::Sequential);
2287 }
2288
2289 None
2290}
2291
2292fn contains_any(text: &str, needles: &[&str]) -> bool {
2293 needles.iter().any(|needle| text.contains(needle))
2294}
2295
2296fn strip_pending_checkbox_prefix(line: &str) -> &str {
2297 let trimmed = line.trim_start();
2298 for prefix in ["[ ]", "[/]", "[x]", "[X]"] {
2299 if let Some(rest) = trimmed.strip_prefix(prefix) {
2300 return rest.trim_start();
2301 }
2302 }
2303 if let Some(inner) = trimmed.strip_prefix("[/")
2304 && let Some(close) = inner.find(']')
2305 {
2306 let gate_type = &inner[..close];
2307 if !gate_type.is_empty()
2308 && gate_type
2309 .chars()
2310 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2311 {
2312 return inner[close + 1..].trim_start();
2313 }
2314 }
2315 trimmed
2316}
2317
2318fn starts_with_imperative_verb(line: &str) -> bool {
2319 let mut words = line.split_whitespace();
2320 let Some(first) = words.next() else {
2321 return false;
2322 };
2323 if IMPERATIVE_LEADING_VERBS.contains(&first) {
2324 return true;
2325 }
2326 matches!((first, words.next()), ("clean", Some("up")))
2327}
2328
2329pub fn is_stale_snapshot(snapshot_content: &str, document_content: &str) -> bool {
2339 let snap_stripped = strip_comments(snapshot_content);
2340 let doc_stripped = strip_comments(document_content);
2341
2342 if doc_stripped.len() <= snap_stripped.len() {
2344 return false;
2345 }
2346
2347 let snap_trimmed = snap_stripped.trim_end();
2350 let doc_trimmed = doc_stripped.trim_end();
2351
2352 if !doc_trimmed.starts_with(snap_trimmed) {
2353 return false;
2354 }
2355
2356 let extra = &doc_stripped[snap_trimmed.len()..];
2358 let extra_trimmed = extra.trim();
2359
2360 if extra_trimmed.is_empty() {
2361 return false;
2362 }
2363
2364 if !extra_trimmed.contains("## Assistant") {
2366 return false;
2367 }
2368
2369 let parts: Vec<&str> = extra_trimmed.split("## User").collect();
2372 if let Some(last_user_block) = parts.last() {
2373 let user_content = last_user_block.trim();
2374 user_content.is_empty()
2377 } else {
2378 false
2380 }
2381}
2382
2383const APPROVAL_WORDS: &[&str] = &[
2389 "go", "yes", "do", "ok", "continue", "approve", "approved", "y", "yep", "yeah", "sure",
2390 "proceed", "lgtm",
2391];
2392
2393pub fn classify_diff(diff_text: &str) -> DiffClassification {
2397 let mut added: Vec<String> = Vec::new();
2399 let mut removed: Vec<String> = Vec::new();
2400 let mut added_block_count = 0usize;
2401 let mut in_added_block = false;
2402
2403 for line in diff_text.lines() {
2404 if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
2406 in_added_block = false;
2407 continue;
2408 }
2409 if let Some(content) = line.strip_prefix('+') {
2410 added.push(content.to_string());
2411 if !in_added_block {
2412 added_block_count += 1;
2413 in_added_block = true;
2414 }
2415 } else if let Some(content) = line.strip_prefix('-') {
2416 removed.push(content.to_string());
2417 in_added_block = false;
2418 } else {
2419 in_added_block = false;
2421 }
2422 }
2423
2424 let added_content: Vec<&str> = added
2426 .iter()
2427 .map(|s| s.trim())
2428 .filter(|s| !s.is_empty())
2429 .collect();
2430 let removed_content: Vec<&str> = removed
2431 .iter()
2432 .map(|s| s.trim())
2433 .filter(|s| !s.is_empty())
2434 .collect();
2435
2436 if is_boundary_artifact(&added_content, &removed_content) {
2438 return DiffClassification {
2439 diff_type: DiffType::BoundaryArtifact,
2440 diff_type_reason: "only boundary marker or (HEAD) changes".into(),
2441 };
2442 }
2443
2444 if added_content.len() == 1 && removed_content.is_empty() {
2446 let word = added_content[0].to_lowercase();
2447 let word_clean = word.trim_end_matches(|c: char| c.is_ascii_punctuation());
2449 if APPROVAL_WORDS.contains(&word_clean) {
2450 return DiffClassification {
2451 diff_type: DiffType::Approval,
2452 diff_type_reason: format!("single approval word: \"{}\"", added_content[0]),
2453 };
2454 }
2455 }
2456
2457 if added_content.len() == 1 && removed_content.is_empty() && added_content[0].ends_with('?') {
2459 return DiffClassification {
2460 diff_type: DiffType::SimpleQuestion,
2461 diff_type_reason: format!(
2462 "single question: \"{}\"",
2463 truncate_for_reason(added_content[0])
2464 ),
2465 };
2466 }
2467
2468 if !added_content.is_empty()
2470 && !removed_content.is_empty()
2471 && is_annotation(&added_content, &removed_content)
2472 {
2473 return DiffClassification {
2474 diff_type: DiffType::Annotation,
2475 diff_type_reason: "inline edit to existing content".into(),
2476 };
2477 }
2478
2479 if added_content.is_empty() && !removed_content.is_empty() {
2481 return DiffClassification {
2482 diff_type: DiffType::StructuralChange,
2483 diff_type_reason: format!("{} lines removed, no additions", removed_content.len()),
2484 };
2485 }
2486
2487 let has_separator = added.iter().any(|l| l.trim() == "---");
2489 if has_separator && added_content.len() >= 2 {
2490 let section_count = added_content
2491 .split(|l| *l == "---")
2492 .filter(|s| !s.is_empty())
2493 .count();
2494 if section_count >= 2 {
2495 return DiffClassification {
2496 diff_type: DiffType::MultiTopic,
2497 diff_type_reason: format!("{} topics separated by ---", section_count),
2498 };
2499 }
2500 }
2501 if added_block_count >= 2 && added_content.len() >= 2 {
2502 return DiffClassification {
2503 diff_type: DiffType::MultiTopic,
2504 diff_type_reason: format!("{} distinct added blocks", added_block_count),
2505 };
2506 }
2507
2508 DiffClassification {
2510 diff_type: DiffType::ContentAddition,
2511 diff_type_reason: format!(
2512 "{} lines added{}",
2513 added_content.len(),
2514 if !removed_content.is_empty() {
2515 format!(", {} removed", removed_content.len())
2516 } else {
2517 String::new()
2518 }
2519 ),
2520 }
2521}
2522
2523fn is_boundary_artifact(added: &[&str], removed: &[&str]) -> bool {
2525 let is_boundary_line =
2526 |line: &str| -> bool { is_boundary_artifact_line(line) || line.is_empty() };
2527 if added.is_empty() && removed.is_empty() {
2529 return false;
2530 }
2531 if added.iter().all(|l| is_boundary_line(l)) && removed.iter().all(|l| is_boundary_line(l)) {
2533 return true;
2534 }
2535 if added.len() == removed.len() {
2537 return added.iter().zip(removed.iter()).all(|(a, r)| {
2538 let a_trim = a.trim();
2539 let r_trim = r.trim();
2540 if a_trim == r_trim {
2541 return true;
2542 }
2543 if a_trim.starts_with("<!-- agent:boundary:")
2544 && r_trim.starts_with("<!-- agent:boundary:")
2545 {
2546 return true;
2547 }
2548 if is_head_boundary_artifact(a_trim)
2549 && a_trim
2550 .strip_suffix(" (HEAD)")
2551 .is_some_and(|base| base.trim() == r_trim)
2552 {
2553 return true;
2554 }
2555 if is_head_boundary_artifact(r_trim)
2556 && r_trim
2557 .strip_suffix(" (HEAD)")
2558 .is_some_and(|base| base.trim() == a_trim)
2559 {
2560 return true;
2561 }
2562 false
2563 });
2564 }
2565 false
2566}
2567
2568fn is_annotation(added: &[&str], removed: &[&str]) -> bool {
2573 if added.len() != removed.len() {
2574 return false;
2575 }
2576 added.iter().zip(removed.iter()).all(|(a, r)| {
2577 a.starts_with(r)
2579 || {
2581 let min_len = a.len().min(r.len());
2582 if min_len < 3 {
2583 return false;
2584 }
2585 let common = a
2586 .chars()
2587 .zip(r.chars())
2588 .take_while(|(a, b)| a == b)
2589 .count();
2590 common > min_len * 6 / 10
2591 }
2592 })
2593}
2594
2595fn truncate_for_reason(s: &str) -> &str {
2597 if s.len() <= 80 { s } else { &s[..80] }
2598}
2599
2600#[cfg(test)]
2601mod tests {
2602 use super::*;
2603
2604 #[test]
2605 fn diff_format_additions() {
2606 use similar::{ChangeTag, TextDiff};
2607 let previous = "line1\n";
2608 let current = "line1\nline2\n";
2609 let diff = TextDiff::from_lines(previous, current);
2610 let has_insert = diff
2611 .iter_all_changes()
2612 .any(|c| c.tag() == ChangeTag::Insert);
2613 assert!(has_insert);
2614 }
2615
2616 #[test]
2617 fn diff_format_deletions() {
2618 use similar::{ChangeTag, TextDiff};
2619 let previous = "line1\nline2\n";
2620 let current = "line1\n";
2621 let diff = TextDiff::from_lines(previous, current);
2622 let has_delete = diff
2623 .iter_all_changes()
2624 .any(|c| c.tag() == ChangeTag::Delete);
2625 assert!(has_delete);
2626 }
2627
2628 #[test]
2629 fn diff_format_unchanged() {
2630 use similar::{ChangeTag, TextDiff};
2631 let content = "line1\nline2\n";
2632 let diff = TextDiff::from_lines(content, content);
2633 let all_equal = diff.iter_all_changes().all(|c| c.tag() == ChangeTag::Equal);
2634 assert!(all_equal);
2635 }
2636
2637 #[test]
2638 fn diff_format_mixed() {
2639 use similar::{ChangeTag, TextDiff};
2640 let previous = "line1\nline2\nline3\n";
2641 let current = "line1\nchanged\nline3\n";
2642 let diff = TextDiff::from_lines(previous, current);
2643
2644 let mut output = String::new();
2645 for change in diff.iter_all_changes() {
2646 let prefix = match change.tag() {
2647 ChangeTag::Delete => "-",
2648 ChangeTag::Insert => "+",
2649 ChangeTag::Equal => " ",
2650 };
2651 output.push_str(prefix);
2652 output.push_str(change.value());
2653 }
2654 assert!(output.contains(" line1\n"));
2655 assert!(output.contains("-line2\n"));
2656 assert!(output.contains("+changed\n"));
2657 assert!(output.contains(" line3\n"));
2658 }
2659
2660 #[test]
2663 fn strip_html_comment() {
2664 let input = "before\n<!-- a comment -->\nafter\n";
2665 assert_eq!(strip_comments(input), "before\nafter\n");
2666 }
2667
2668 #[test]
2669 fn strip_multiline_html_comment() {
2670 let input = "before\n<!--\nmulti\nline\n-->\nafter\n";
2671 assert_eq!(strip_comments(input), "before\nafter\n");
2672 }
2673
2674 #[test]
2675 fn strip_link_ref_comment() {
2676 let input = "before\n[//]: # (a comment)\nafter\n";
2677 assert_eq!(strip_comments(input), "before\nafter\n");
2678 }
2679
2680 #[test]
2681 fn preserve_agent_markers() {
2682 let input = "<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
2683 assert_eq!(strip_comments(input), input);
2684 }
2685
2686 #[test]
2687 fn strip_regular_keep_agent_marker() {
2688 let input = "<!-- regular comment -->\n<!-- agent:s -->\ndata\n<!-- /agent:s -->\n";
2689 assert_eq!(
2690 strip_comments(input),
2691 "<!-- agent:s -->\ndata\n<!-- /agent:s -->\n"
2692 );
2693 }
2694
2695 #[test]
2696 fn strip_inline_comment() {
2697 let input = "text <!-- note --> more\n";
2699 let result = strip_comments(input);
2700 assert_eq!(result, "text more\n");
2701 }
2702
2703 #[test]
2704 fn no_comments_unchanged() {
2705 let input = "# Title\n\nJust text.\n";
2706 assert_eq!(strip_comments(input), input);
2707 }
2708
2709 #[test]
2710 fn empty_document() {
2711 assert_eq!(strip_comments(""), "");
2712 }
2713
2714 #[test]
2717 fn stale_snapshot_detects_completed_exchange() {
2718 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2719 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";
2720 assert!(is_stale_snapshot(snapshot, document));
2721 }
2722
2723 #[test]
2724 fn stale_snapshot_false_when_user_has_new_content() {
2725 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2726 let document =
2727 "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nNew question here\n";
2728 assert!(!is_stale_snapshot(snapshot, document));
2729 }
2730
2731 #[test]
2732 fn stale_snapshot_false_when_identical() {
2733 let content = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
2734 assert!(!is_stale_snapshot(content, content));
2735 }
2736
2737 #[test]
2738 fn stale_snapshot_false_when_no_assistant_block() {
2739 let snapshot = "## User\n\nHello\n\n";
2740 let document = "## User\n\nHello\n\nSome random text\n\n## User\n\n";
2741 assert!(!is_stale_snapshot(snapshot, document));
2742 }
2743
2744 #[test]
2745 fn stale_snapshot_multiple_exchanges_stale() {
2746 let snapshot = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\n";
2747 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";
2748 assert!(is_stale_snapshot(snapshot, document));
2749 }
2750
2751 #[test]
2752 fn stale_snapshot_with_inline_annotation_not_stale() {
2753 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
2754 let document =
2756 "## User\n\nHello\n\n## Assistant\n\nHi there\n\nPlease elaborate\n\n## User\n\n";
2757 assert!(!is_stale_snapshot(snapshot, document));
2759 }
2760
2761 #[test]
2762 fn stale_snapshot_ignores_comments_in_detection() {
2763 let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
2764 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";
2765 assert!(is_stale_snapshot(snapshot, document));
2767 }
2768
2769 #[test]
2770 fn copy_on_read_guard_skips_recovery_when_snapshot_modified() {
2771 use std::time::SystemTime;
2774
2775 let t1 = Some(SystemTime::UNIX_EPOCH);
2776 let t2 = Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1));
2777
2778 assert_eq!(t1, t1, "same mtime should allow recovery");
2780
2781 assert_ne!(t1, t2, "different mtime should block recovery");
2783
2784 let none: Option<SystemTime> = None;
2786 assert_eq!(none, none, "both None should allow recovery");
2787 }
2788
2789 #[test]
2792 fn strip_preserves_comment_syntax_in_inline_backticks() {
2793 let input =
2795 "Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n";
2796 let result = strip_comments(input);
2797 assert_eq!(
2798 result,
2799 "Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n"
2800 );
2801 }
2802
2803 #[test]
2804 fn strip_preserves_comment_syntax_in_fenced_code_block() {
2805 let input = "before\n```\n<!-- not a comment -->\n```\nafter\n";
2806 let result = strip_comments(input);
2807 assert_eq!(result, input);
2808 }
2809
2810 #[test]
2811 fn strip_backtick_comment_before_agent_marker() {
2812 let input = "\
2815Text mentions `<!--` as a trigger.\n\
2816More text here.\n\
2817New user content.\n\
2818<!-- /agent:exchange -->\n";
2819 let result = strip_comments(input);
2820 assert_eq!(result, input);
2821 }
2822
2823 #[test]
2824 fn strip_multiple_backtick_comments_in_exchange() {
2825 let snapshot = "\
2827<!-- agent:exchange -->\n\
2828Discussion about `<!--` triggers.\n\
2829- `<!-- agent:NAME -->` paired markers\n\
2830<!-- /agent:exchange -->\n";
2831 let current = "\
2832<!-- agent:exchange -->\n\
2833Discussion about `<!--` triggers.\n\
2834- `<!-- agent:NAME -->` paired markers\n\
2835\n\
2836Please fix the bug.\n\
2837<!-- /agent:exchange -->\n";
2838
2839 let snap_stripped = strip_comments(snapshot);
2840 let curr_stripped = strip_comments(current);
2841 assert_ne!(
2842 snap_stripped, curr_stripped,
2843 "inline edits after backtick-comment text must be detected"
2844 );
2845 }
2846
2847 fn make_diff(added: &[&str], removed: &[&str]) -> String {
2850 let mut lines = vec!["--- snapshot", "+++ document", "@@ -1,5 +1,5 @@"];
2851 for r in removed {
2852 lines.push(r);
2853 }
2854 lines.push(" context line");
2855 for a in added {
2856 lines.push(a);
2857 }
2858 lines.join("\n")
2859 }
2860
2861 #[test]
2862 fn classify_approval() {
2863 let diff = make_diff(&["+go"], &[]);
2864 let c = classify_diff(&diff);
2865 assert_eq!(c.diff_type, DiffType::Approval);
2866 assert!(c.diff_type_reason.contains("go"));
2867 }
2868
2869 #[test]
2870 fn classify_approval_case_insensitive() {
2871 let diff = make_diff(&["+Yes"], &[]);
2872 let c = classify_diff(&diff);
2873 assert_eq!(c.diff_type, DiffType::Approval);
2874 }
2875
2876 #[test]
2877 fn classify_simple_question() {
2878 let diff = make_diff(&["+what is the release process?"], &[]);
2879 let c = classify_diff(&diff);
2880 assert_eq!(c.diff_type, DiffType::SimpleQuestion);
2881 }
2882
2883 #[test]
2884 fn classify_boundary_artifact() {
2885 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-### Re: Something (HEAD)\n+### Re: Something\n";
2886 let c = classify_diff(diff);
2887 assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
2888 }
2889
2890 #[test]
2891 fn classify_boundary_uuid() {
2892 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-<!-- agent:boundary:abc123 -->\n+<!-- agent:boundary:def456 -->\n";
2893 let c = classify_diff(diff);
2894 assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
2895 }
2896
2897 #[test]
2898 fn classify_head_mention_in_user_prose_as_content_addition() {
2899 let diff = make_diff(
2900 &[
2901 "+`❯ ` prompt prefix is being stripped away by the uncommitted user affordance that adds the ` (HEAD)` suffix. spec-test-build-install-commit-push",
2902 ],
2903 &[],
2904 );
2905 let c = classify_diff(&diff);
2906 assert_eq!(c.diff_type, DiffType::ContentAddition);
2907 }
2908
2909 #[test]
2910 fn classify_structural_change() {
2911 let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,3 @@\n context\n-removed line one\n-removed line two\n context\n";
2912 let c = classify_diff(diff);
2913 assert_eq!(c.diff_type, DiffType::StructuralChange);
2914 }
2915
2916 #[test]
2917 fn classify_multi_topic() {
2918 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";
2920 let c = classify_diff(diff);
2921 assert_eq!(c.diff_type, DiffType::MultiTopic);
2922 }
2923
2924 #[test]
2925 fn classify_multi_topic_with_separator() {
2926 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n context\n+question one?\n+---\n+do something else\n context end\n";
2928 let c = classify_diff(diff);
2929 assert_eq!(c.diff_type, DiffType::MultiTopic);
2930 }
2931
2932 #[test]
2933 fn classify_content_addition() {
2934 let diff = make_diff(&["+implement the feature using Rust"], &[]);
2935 let c = classify_diff(&diff);
2936 assert_eq!(c.diff_type, DiffType::ContentAddition);
2937 }
2938
2939 #[test]
2940 fn classify_annotation() {
2941 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";
2942 let c = classify_diff(diff);
2943 assert_eq!(c.diff_type, DiffType::Annotation);
2944 }
2945
2946 #[test]
2947 fn classify_approval_in_sentence_is_content() {
2948 let diff = make_diff(&["+let's go ahead and implement the feature"], &[]);
2950 let c = classify_diff(&diff);
2951 assert_eq!(c.diff_type, DiffType::ContentAddition);
2952 }
2953
2954 #[test]
2955 fn classify_single_separator_not_multi_topic() {
2956 let diff = make_diff(&["+---"], &[]);
2958 let c = classify_diff(&diff);
2959 assert_ne!(c.diff_type, DiffType::MultiTopic);
2961 }
2962
2963 #[test]
2964 fn classify_question_mark_in_multiline_is_content() {
2965 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";
2967 let c = classify_diff(diff);
2968 assert_ne!(c.diff_type, DiffType::SimpleQuestion);
2971 }
2972
2973 #[test]
2976 fn annotate_diff_additions() {
2977 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n context line\n+new user line\n more context\n";
2978 let annotated = annotate_diff(diff).unwrap();
2979 assert!(annotated.contains("[user+] new user line"));
2980 assert!(annotated.contains("[agent] context line"));
2981 }
2982
2983 #[test]
2984 fn annotate_diff_removals() {
2985 let diff =
2986 "--- snapshot\n+++ document\n@@ -1,3 +1,2 @@\n context\n-removed line\n context\n";
2987 let annotated = annotate_diff(diff).unwrap();
2988 assert!(annotated.contains("[user-] removed line"));
2989 }
2990
2991 #[test]
2992 fn annotate_diff_modifications() {
2993 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";
2994 let annotated = annotate_diff(diff).unwrap();
2995 assert!(annotated.contains("[user~] The fix is deployed: confirmed in prod"));
2996 assert!(!annotated.contains("[user-] The fix is deployed"));
2998 }
2999
3000 #[test]
3001 fn annotate_diff_context() {
3002 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n line one\n line two\n+added\n line three\n";
3003 let annotated = annotate_diff(diff).unwrap();
3004 assert!(annotated.contains("[agent] line one"));
3005 assert!(annotated.contains("[agent] line two"));
3006 assert!(annotated.contains("[agent] line three"));
3007 }
3008
3009 #[test]
3010 fn annotate_diff_empty() {
3011 let diff = "--- snapshot\n+++ document\n";
3012 assert!(annotate_diff(diff).is_none());
3013 }
3014
3015 #[test]
3018 fn inline_annotations_user_addition_between_agent_lines() {
3019 let annotated = "[agent] previous agent line\n\
3020 [user+] This is wrong, fix it\n\
3021 [agent] more agent content";
3022 let anns = extract_inline_annotations(annotated);
3023 assert_eq!(anns, vec!["This is wrong, fix it"]);
3024 }
3025
3026 #[test]
3027 fn inline_annotations_user_modification_between_agent_lines() {
3028 let annotated = "[agent] context before\n\
3029 [user~] The corrected line\n\
3030 [agent] context after";
3031 let anns = extract_inline_annotations(annotated);
3032 assert_eq!(anns, vec!["The corrected line"]);
3033 }
3034
3035 #[test]
3036 fn inline_annotations_user_addition_at_end_is_not_inline() {
3037 let annotated = "[agent] agent content\n\
3038 [agent] more agent content\n\
3039 [user+] New user input at end";
3040 let anns = extract_inline_annotations(annotated);
3041 assert!(anns.is_empty());
3042 }
3043
3044 #[test]
3045 fn inline_annotations_component_markers_not_substantive() {
3046 let annotated = "[agent] response prose\n\
3048 [user+] The fix did not seem to work\n\
3049 [agent] <!-- /agent:exchange -->\n\
3050 [agent] \n\
3051 [agent] ## Pending / Not Built\n\
3052 [agent] \n\
3053 [agent] <!-- agent:pending -->";
3054 let anns = extract_inline_annotations(annotated);
3055 assert!(
3056 anns.is_empty(),
3057 "component markers should not make end-of-exchange input inline"
3058 );
3059 }
3060
3061 #[test]
3062 fn inline_annotations_head_boundary_artifact_excluded() {
3063 let annotated = "[agent] previous content\n\
3065 [user~] ### Re: topic — sonnet-4-6 (HEAD)\n\
3066 [agent] response prose\n\
3067 [agent] more content";
3068 let anns = extract_inline_annotations(annotated);
3069 assert!(
3070 anns.is_empty(),
3071 "(HEAD) boundary reposition should not be an inline annotation"
3072 );
3073 }
3074
3075 #[test]
3076 fn inline_annotations_real_correction_between_prose() {
3077 let annotated = "[agent] The score is 5 out of 10.\n\
3079 [user+] This is wrong. Score should be 8-9.\n\
3080 [agent] Here is more analysis below.";
3081 let anns = extract_inline_annotations(annotated);
3082 assert_eq!(anns, vec!["This is wrong. Score should be 8-9."]);
3083 }
3084
3085 #[test]
3086 fn inline_annotations_multiple() {
3087 let annotated = "[agent] ### Re: topic\n\
3088 [user+] First correction\n\
3089 [agent] agent paragraph\n\
3090 [user+] Second correction\n\
3091 [agent] agent closing\n\
3092 [user+] New input at end";
3093 let anns = extract_inline_annotations(annotated);
3094 assert_eq!(anns, vec!["First correction", "Second correction"]);
3095 }
3096
3097 #[test]
3098 fn inline_annotations_skips_blank_user_lines() {
3099 let annotated = "[agent] agent line\n\
3100 [user+] \n\
3101 [agent] more agent";
3102 let anns = extract_inline_annotations(annotated);
3103 assert!(anns.is_empty());
3104 }
3105
3106 #[test]
3107 fn inline_annotations_empty_annotated_diff() {
3108 let anns = extract_inline_annotations("");
3109 assert!(anns.is_empty());
3110 }
3111
3112 #[test]
3113 fn inline_annotations_claudescore_table_scenario() {
3114 let annotated = "[agent] | Category | Score |\n\
3117 [agent] |----------|-------|\n\
3118 [agent] | Quality | 7 |\n\
3119 [user+] This is wrong. Do not lower expert scores.\n\
3120 [agent] | Speed | 8 |\n\
3121 [user+] We may need to broaden the gate.\n\
3122 [agent] | Total | 7.5 |";
3123 let anns = extract_inline_annotations(annotated);
3124 assert_eq!(
3125 anns,
3126 vec![
3127 "This is wrong. Do not lower expert scores.",
3128 "We may need to broaden the gate.",
3129 ]
3130 );
3131 }
3132
3133 #[test]
3134 fn classify_prompt_bearing_changes_promotes_inline_prompt_to_prompt_target() {
3135 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3136 The prior explanation was incomplete\n\
3137 +Why was the `❯` prefix omitted here?\n\
3138 The rest of the response stays the same\n";
3139 let changes = classify_prompt_bearing_changes(diff);
3140 assert_eq!(changes.len(), 1);
3141 assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3142 assert_eq!(changes[0].text, "Why was the `❯` prefix omitted here?");
3143 }
3144
3145 #[test]
3146 fn classify_prompt_bearing_changes_promotes_plain_exchange_tail_to_prompt_target() {
3147 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3148Done.\n\
3149+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\
3150<!-- /agent:exchange -->\n";
3151 let changes = classify_prompt_bearing_changes(diff);
3152 assert_eq!(changes.len(), 1);
3153 assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3154 assert_eq!(
3155 changes[0].text,
3156 "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"
3157 );
3158 }
3159
3160 #[test]
3161 fn classify_prompt_bearing_changes_promotes_bare_slash_command_to_prompt_target() {
3162 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3163### Re: older — gpt-5\n\
3164+/clear\n\
3165<!-- /agent:exchange -->\n";
3166 let changes = classify_prompt_bearing_changes(diff);
3167 assert_eq!(changes.len(), 1);
3168 assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
3169 assert_eq!(changes[0].text, "/clear");
3170 assert!(line_looks_like_fresh_prompt_after_response("/clear"));
3171 assert!(!text_line_looks_like_prompt_target(
3172 "/home/brian/work/foo.md"
3173 ));
3174 }
3175
3176 #[test]
3177 fn prefixed_markdown_response_labels_are_not_prompt_targets() {
3178 for line in [
3179 "❯ **Verification:** Both redirects confirmed via `curl`.",
3180 "❯ Commit / push:",
3181 "❯ **Commit / push:**",
3182 "❯ - **Verification:** `cargo test` passed.",
3183 "❯ 1. **What changed:** normalized response labels.",
3184 ] {
3185 assert!(
3186 !text_line_looks_like_prompt_target(line),
3187 "assistant response label must not be classified as a prompt target: {line}"
3188 );
3189 assert!(
3190 line_looks_like_plain_response_after_prompt(line),
3191 "assistant response label must remain response prose: {line}"
3192 );
3193 }
3194 }
3195
3196 #[test]
3197 fn prefixed_user_followup_after_response_still_starts_prompt() {
3198 for line in [
3199 "❯ verify deploy status",
3200 "❯ Verification failed; what next?",
3201 "❯ do [#respfx]. spec-test-build-install-commit-push",
3202 ] {
3203 assert!(
3204 text_line_looks_like_prompt_target(line),
3205 "real user follow-up must stay prompt-bearing: {line}"
3206 );
3207 assert!(
3208 line_looks_like_fresh_prompt_after_response(line),
3209 "real user follow-up must start a new prompt run: {line}"
3210 );
3211 }
3212 }
3213
3214 #[test]
3215 fn classify_prompt_bearing_changes_ignores_prefixed_markdown_response_label() {
3216 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3217 ### Re: done — gpt-5\n\
3218 +❯ **Verification:** Both redirects confirmed via `curl`.\n\
3219 <!-- /agent:exchange -->\n";
3220 let changes = classify_prompt_bearing_changes(diff);
3221 assert!(
3222 changes.is_empty(),
3223 "prefixed assistant response label must not reopen a cycle: {changes:?}"
3224 );
3225 }
3226
3227 #[test]
3228 fn classify_prompt_bearing_changes_marks_inline_correction_as_content_edit() {
3229 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
3230 The service returned 401 from this endpoint\n\
3231 +The service returned 503 from this endpoint\n\
3232 The rest of the response stays the same\n";
3233 let changes = classify_prompt_bearing_changes(diff);
3234 assert_eq!(changes.len(), 1);
3235 assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3236 assert_eq!(
3237 changes[0].text,
3238 "The service returned 503 from this endpoint"
3239 );
3240 }
3241
3242 #[test]
3243 fn classify_prompt_bearing_changes_marks_response_heading_as_recovery_artifact() {
3244 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
3245 ctx\n\
3246 +### Re: missed patchback — gpt-5\n\
3247 +Patched after the fact.\n\
3248 context end\n";
3249 let changes = classify_prompt_bearing_changes(diff);
3250 assert_eq!(changes.len(), 1);
3251 assert_eq!(changes[0].kind, PromptBearingChangeKind::RecoveryArtifact);
3252 assert_eq!(
3253 changes[0].text,
3254 "### Re: missed patchback — gpt-5\nPatched after the fact."
3255 );
3256 }
3257
3258 #[test]
3259 fn classify_prompt_bearing_changes_marks_boundary_only_edit_as_boundary_artifact() {
3260 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n\
3261 -### Re: Something\n\
3262 +### Re: Something (HEAD)\n";
3263 let changes = classify_prompt_bearing_changes(diff);
3264 assert_eq!(changes.len(), 1);
3265 assert_eq!(changes[0].kind, PromptBearingChangeKind::BoundaryArtifact);
3266 assert_eq!(changes[0].text, "### Re: Something (HEAD)");
3267 }
3268
3269 #[test]
3270 fn classify_prompt_bearing_changes_preserves_mixed_triage_order() {
3271 let diff = "--- snapshot\n+++ document\n@@ -1,7 +1,12 @@\n\
3272 The service returned 401 from this endpoint\n\
3273 +The service returned 503 from this endpoint\n\
3274 The rest of the response stays the same\n\
3275 +### Re: delayed patchback — gpt-5\n\
3276 +Patched after the fact.\n\
3277 Context after repair\n\
3278 +<!-- agent:boundary:test-boundary -->\n\
3279 More context\n\
3280 +Why was the `❯` prefix omitted here?\n\
3281 The response continues below\n";
3282 let changes = classify_prompt_bearing_changes(diff);
3283 assert_eq!(changes.len(), 4);
3284 assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3285 assert_eq!(
3286 changes[0].text,
3287 "The service returned 503 from this endpoint"
3288 );
3289 assert_eq!(changes[1].kind, PromptBearingChangeKind::RecoveryArtifact);
3290 assert_eq!(
3291 changes[1].text,
3292 "### Re: delayed patchback — gpt-5\nPatched after the fact."
3293 );
3294 assert_eq!(changes[2].kind, PromptBearingChangeKind::BoundaryArtifact);
3295 assert_eq!(changes[2].text, "<!-- agent:boundary:test-boundary -->");
3296 assert_eq!(changes[3].kind, PromptBearingChangeKind::PromptTarget);
3297 assert_eq!(changes[3].text, "Why was the `❯` prefix omitted here?");
3298 }
3299
3300 #[test]
3301 fn classify_prompt_bearing_changes_ignores_raw_answered_stale_exchange_tail() {
3302 let diff = concat!(
3303 "--- snapshot\n",
3304 "+++ document\n",
3305 "@@ -19,11 +19,11 @@\n",
3306 " ---\n",
3307 " \n",
3308 " ## Status\n",
3309 " \n",
3310 " <!-- agent:status patch=replace -->\n",
3311 "-Blocked on environment: GitHub is unreachable from this sandbox, so I cannot rename `ClaudeScore/BuildPartyInvestorDemo` or add the follow-up submodule here.\n",
3312 "+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",
3313 " <!-- /agent:status -->\n",
3314 " \n",
3315 " ## Exchange\n",
3316 " \n",
3317 " <!-- agent:exchange patch=append -->\n",
3318 "@@ -60,10 +60,17 @@\n",
3319 " ```bash\n",
3320 " git submodule add git@github.com:ClaudeScore/buildparty-investor-demo.git buildparty-investor-demo\n",
3321 " ```\n",
3322 " \n",
3323 " 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",
3324 "+I renamed the repo to ClaudeScore/buildparty-investor-demo. Please update references\n",
3325 "+I updated the repo-local references to the renamed GitHub repo.\n",
3326 "+\n",
3327 "+- `.gitmodules` now uses `git@github.com:ClaudeScore/buildparty-investor-demo.git`\n",
3328 "+- The checked-out submodule at `buildparty-investor-demo/` now has `origin` set to the same URL\n",
3329 "+\n",
3330 "+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",
3331 " <!-- /agent:exchange -->\n",
3332 " \n",
3333 " ## Queue\n",
3334 " \n",
3335 " <!-- agent:queue -->\n",
3336 );
3337 let changes = classify_prompt_bearing_changes(diff);
3338 assert_eq!(changes.len(), 1);
3339 assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
3340 assert_eq!(
3341 changes[0].text,
3342 "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."
3343 );
3344 }
3345
3346 #[test]
3347 fn classify_prompt_bearing_changes_ignores_answered_prompt_before_blank_heading_gap() {
3348 let diff = concat!(
3349 "--- snapshot\n",
3350 "+++ document\n",
3351 "@@ -1,3 +1,8 @@\n",
3352 " <!-- agent:exchange patch=append -->\n",
3353 "-<!-- agent:boundary:initial -->\n",
3354 "+❯ do #sim1. spec-test-build-install-commit-push\n",
3355 "+\n",
3356 "+### Re: sim closeout — gpt-5 (HEAD)\n",
3357 "+\n",
3358 "+Done.\n",
3359 "+<!-- agent:boundary:new -->\n",
3360 " <!-- /agent:exchange -->\n",
3361 );
3362
3363 let changes = classify_prompt_bearing_changes(diff);
3364 assert!(
3365 changes
3366 .iter()
3367 .all(|change| change.kind != PromptBearingChangeKind::PromptTarget),
3368 "answered prompt should not remain unresolved: {changes:?}"
3369 );
3370 }
3371
3372 #[test]
3375 fn parse_slash_commands_simple() {
3376 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
3377 let cmds = parse_slash_commands(diff);
3378 assert_eq!(cmds, vec!["/clear"]);
3379 }
3380
3381 #[test]
3382 fn parse_slash_command_only_added_diff_accepts_bare_clear() {
3383 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
3384 assert_eq!(
3385 parse_slash_command_only_added_diff(diff),
3386 Some(vec!["/clear".to_string()])
3387 );
3388 }
3389
3390 #[test]
3391 fn parse_slash_command_only_added_diff_rejects_mixed_prompt_text() {
3392 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n context\n+/clear\n+Why was this answered?\n";
3393 assert_eq!(parse_slash_command_only_added_diff(diff), None);
3394 }
3395
3396 #[test]
3397 fn parse_slash_command_only_added_diff_rejects_fenced_or_blockquoted_commands() {
3398 let fenced = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
3399 assert_eq!(parse_slash_command_only_added_diff(fenced), None);
3400
3401 let quoted = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
3402 assert_eq!(parse_slash_command_only_added_diff(quoted), None);
3403 }
3404
3405 #[test]
3406 fn parse_slash_commands_trims_surrounding_whitespace() {
3407 let diff = "--- snapshot\n+++ queue\n@@ -0,0 +1,1 @@\n+ /clear \n";
3408 let cmds = parse_slash_commands(diff);
3409 assert_eq!(cmds, vec!["/clear"]);
3410 }
3411
3412 #[test]
3413 fn parse_slash_commands_with_args() {
3414 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+/agent-doc foo.md\n";
3415 let cmds = parse_slash_commands(diff);
3416 assert_eq!(cmds, vec!["/agent-doc foo.md"]);
3417 }
3418
3419 #[test]
3420 fn parse_slash_commands_ignores_fenced() {
3421 let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
3422 let cmds = parse_slash_commands(diff);
3423 assert!(cmds.is_empty());
3424 }
3425
3426 #[test]
3427 fn parse_slash_commands_ignores_blockquote() {
3428 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
3429 let cmds = parse_slash_commands(diff);
3430 assert!(cmds.is_empty());
3431 }
3432
3433 #[test]
3434 fn parse_slash_commands_ignores_context_lines() {
3435 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n /clear\n context\n";
3436 let cmds = parse_slash_commands(diff);
3437 assert!(cmds.is_empty());
3438 }
3439
3440 #[test]
3441 fn parse_slash_commands_ignores_removed_lines() {
3442 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,1 @@\n-/clear\n context\n";
3443 let cmds = parse_slash_commands(diff);
3444 assert!(cmds.is_empty());
3445 }
3446
3447 #[test]
3448 fn parse_slash_commands_requires_letter_after_slash() {
3449 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/ foo\n+//comment\n";
3451 let cmds = parse_slash_commands(diff);
3452 assert!(cmds.is_empty());
3453 }
3454
3455 #[test]
3456 fn parse_slash_commands_multiple() {
3457 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/clear\n+/agent-doc foo.md\n";
3458 let cmds = parse_slash_commands(diff);
3459 assert_eq!(cmds, vec!["/clear", "/agent-doc foo.md"]);
3460 }
3461
3462 #[test]
3463 fn parse_slash_commands_rejects_absolute_paths() {
3464 let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3468 +/home/brian/work/foo.md\n\
3469 +/tmp/scratch\n\
3470 +/usr/local/bin/agent-doc\n\
3471 +/var\n";
3472 let cmds = parse_slash_commands(diff);
3473 assert_eq!(cmds, vec!["/var"]);
3477 }
3478
3479 #[test]
3480 fn parse_slash_commands_rejects_uppercase_and_symbols() {
3481 let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3483 +/Clear\n\
3484 +/foo.bar\n\
3485 +/foo!bang\n\
3486 +/foo#hash\n";
3487 let cmds = parse_slash_commands(diff);
3488 assert!(cmds.is_empty(), "all four must be rejected; got: {cmds:?}");
3489 }
3490
3491 #[test]
3492 fn parse_slash_commands_accepts_namespaced_and_hyphenated() {
3493 let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
3495 +/mcp:reload\n\
3496 +/agent-doc file.md\n\
3497 +/some_thing\n\
3498 +/v2\n";
3499 let cmds = parse_slash_commands(diff);
3500 assert_eq!(
3501 cmds,
3502 vec!["/mcp:reload", "/agent-doc file.md", "/some_thing", "/v2"]
3503 );
3504 }
3505
3506 #[test]
3507 fn detect_orchestration_request_for_synchronous_task_list() {
3508 let diff = "--- snapshot\n+++ document\n@@ -1 +1,7 @@\n ctx\n\
3509+Today is 2026-04-25. Synchronous orcestra.\n\
3510+- do #ss01. Add tests. Run benchmarks.\n\
3511+- do #ss02. Add tests. Run benchmarks.\n\
3512+- do #ss03. Add tests. Run benchmarks.\n";
3513 let request = detect_orchestration_request(diff).expect("expected orchestration request");
3514 assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
3515 assert_eq!(request.task_count, 3);
3516 assert_eq!(
3517 request.trigger_text,
3518 "Today is 2026-04-25. Synchronous orcestra."
3519 );
3520 }
3521
3522 #[test]
3523 fn detect_orchestration_request_for_parallel_batch() {
3524 let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n\
3525+Fan out these benchmark tasks.\n\
3526+- do #a1\n\
3527+- do #a2\n";
3528 let request = detect_orchestration_request(diff).expect("expected orchestration request");
3529 assert_eq!(request.mode, OrchestrationRequestMode::Parallel);
3530 assert_eq!(request.task_count, 2);
3531 }
3532
3533 #[test]
3534 fn detect_orchestration_request_requires_batch_shape() {
3535 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
3536+Run these in order.\n\
3537+- do #a1\n";
3538 assert!(
3539 detect_orchestration_request(diff).is_none(),
3540 "single-item lists should stay as ordinary work, not forced orchestration"
3541 );
3542 }
3543
3544 #[test]
3545 fn detect_orchestration_request_for_prefixed_synchronous_opera_batch() {
3546 let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
3547+❯ synchronous opera\n\
3548+❯ preset #spec-test-build-install-commit-push\n\
3549+❯ - do #jbpfx1\n\
3550+❯ - do #jbpfx2\n";
3551 let request = detect_orchestration_request(diff).expect("expected orchestration request");
3552 assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
3553 assert_eq!(request.task_count, 2);
3554 assert_eq!(
3555 request.trigger_text,
3556 "❯ synchronous opera ❯ preset #spec-test-build-install-commit-push"
3557 );
3558 }
3559
3560 #[test]
3561 fn detect_queue_trigger_do_queue() {
3562 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue\n";
3563 assert!(detect_queue_trigger(diff));
3564 }
3565
3566 #[test]
3567 fn detect_queue_trigger_run_queue() {
3568 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+run queue\n";
3569 assert!(detect_queue_trigger(diff));
3570 }
3571
3572 #[test]
3573 fn detect_queue_trigger_case_insensitive() {
3574 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+Do Queue\n";
3575 assert!(detect_queue_trigger(diff));
3576 }
3577
3578 #[test]
3579 fn detect_queue_trigger_with_prompt_prefix() {
3580 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+❯ do queue\n";
3581 assert!(detect_queue_trigger(diff));
3582 }
3583
3584 #[test]
3585 fn detect_queue_trigger_not_pending_ref() {
3586 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do #queue Phase 2\n";
3587 assert!(!detect_queue_trigger(diff));
3588 }
3589
3590 #[test]
3591 fn detect_queue_trigger_not_in_code_fence() {
3592 let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+do queue\n+```\n";
3593 assert!(!detect_queue_trigger(diff));
3594 }
3595
3596 #[test]
3597 fn detect_queue_trigger_not_in_blockquote() {
3598 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> do queue\n";
3599 assert!(!detect_queue_trigger(diff));
3600 }
3601
3602 #[test]
3603 fn detect_queue_trigger_with_trailing_punct() {
3604 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue.\n";
3605 assert!(detect_queue_trigger(diff));
3606 }
3607
3608 #[test]
3609 fn detect_queue_trigger_not_on_context_line() {
3610 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n do queue\n+other\n";
3611 assert!(!detect_queue_trigger(diff));
3612 }
3613
3614 #[test]
3615 fn suppress_inactive_queue_additions_removes_queue_prompt_lines() {
3616 let current = concat!(
3617 "---\nqueue_active: false\n---\n\n",
3618 "<!-- agent:exchange -->\n",
3619 "### Re: prior — gpt-5\n",
3620 "Done.\n",
3621 "<!-- /agent:exchange -->\n\n",
3622 "<!-- agent:queue -->\n",
3623 "dispatch #spec-test-build-install-commit-push\n",
3624 "- do [#gdbpropscan]\n",
3625 "<!-- /agent:queue -->\n"
3626 );
3627 let diff = concat!(
3628 "--- snapshot\n",
3629 "+++ document\n",
3630 "@@ -7,5 +7,7 @@\n",
3631 " Done.\n",
3632 " <!-- /agent:exchange -->\n",
3633 " \n",
3634 " <!-- agent:queue -->\n",
3635 "+dispatch #spec-test-build-install-commit-push\n",
3636 "+- do [#gdbpropscan]\n",
3637 " <!-- /agent:queue -->\n",
3638 );
3639
3640 let filtered = suppress_inactive_queue_additions(diff, current);
3641
3642 assert!(!filtered.contains("[#gdbpropscan]"));
3643 assert!(!filtered.contains("dispatch #spec-test-build-install-commit-push"));
3644 assert!(classify_prompt_bearing_changes(&filtered).is_empty());
3645 assert!(extract_imperative_directives(&filtered).is_empty());
3646 }
3647
3648 #[test]
3649 fn detect_prompt_preset_requests_from_diff() {
3650 let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
3651+preset #1\n\
3652+presets release-check, #2\n\
3653+preset #1\n\
3654+> preset ignored\n\
3655+```md\n\
3656+preset fenced\n";
3657 assert_eq!(
3658 detect_prompt_preset_requests(diff),
3659 vec![
3660 "#1".to_string(),
3661 "release-check".to_string(),
3662 "#2".to_string()
3663 ]
3664 );
3665 }
3666
3667 #[test]
3668 fn extract_prompt_preset_requests_from_text_ignores_fences_and_blockquotes() {
3669 let text = "synchronous orchestra\npreset #1\n> preset quoted\n\n```md\npreset fenced\n```\npresets release-check and #2\n";
3670 assert_eq!(
3671 extract_prompt_preset_requests_from_text(text),
3672 vec![
3673 "#1".to_string(),
3674 "release-check".to_string(),
3675 "#2".to_string()
3676 ]
3677 );
3678 }
3679
3680 #[test]
3681 fn extract_imperative_directives_detects_do_and_build_push() {
3682 let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
3683 +do #6zyp. update spec + tests. build + install for local testing. commit + push\n\
3684 +do [#dodone]. spec-test-build-install-commit-push\n\
3685 +do [#plainid]\n\
3686 +run benchmarks\n";
3687 let directives = extract_imperative_directives(diff);
3688 assert_eq!(
3689 directives,
3690 vec![
3691 "do #6zyp. update spec + tests. build + install for local testing. commit + push",
3692 "do [#dodone]. spec-test-build-install-commit-push",
3693 "do [#plainid]",
3694 "run benchmarks",
3695 ]
3696 );
3697 assert!(diff_contains_imperative_directive(diff));
3698 }
3699
3700 #[test]
3701 fn extract_imperative_directives_detects_pending_item_natural_language() {
3702 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
3703 +- [ ] [#n8q4] Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE\n";
3704 let directives = extract_imperative_directives(diff);
3705 assert_eq!(
3706 directives,
3707 vec!["Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE"]
3708 );
3709 assert!(diff_contains_imperative_directive(diff));
3710 }
3711
3712 #[test]
3713 fn extract_imperative_directives_detects_long_custom_pending_id() {
3714 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
3715 +- [ ] [#sdig2matrix] Fix the custom backlog id normalization path\n";
3716 let directives = extract_imperative_directives(diff);
3717 assert_eq!(
3718 directives,
3719 vec!["Fix the custom backlog id normalization path"]
3720 );
3721 assert!(diff_contains_imperative_directive(diff));
3722 }
3723
3724 #[test]
3725 fn extract_imperative_directives_ignores_blockquotes_and_fences() {
3726 let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
3727 +> do #skip\n\
3728 +```\n\
3729 +run tests\n\
3730 +```\n\
3731 +plain note\n";
3732 let directives = extract_imperative_directives(diff);
3733 assert!(
3734 directives.is_empty(),
3735 "unexpected directives: {directives:?}"
3736 );
3737 assert!(!diff_contains_imperative_directive(diff));
3738 }
3739
3740 #[test]
3741 fn diff_contains_imperative_directive_for_approval_word() {
3742 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+go\n";
3743 assert!(diff_contains_imperative_directive(diff));
3744 }
3745
3746 #[test]
3747 fn detect_exchange_compaction_request_matches_bare_directive() {
3748 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+compact exchange\n";
3749 assert!(detect_exchange_compaction_request(diff));
3750 }
3751
3752 #[test]
3753 fn detect_exchange_compaction_request_matches_prompt_prefixed_variant() {
3754 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";
3755 assert!(detect_exchange_compaction_request(diff));
3756 }
3757
3758 #[test]
3759 fn detect_exchange_compaction_request_ignores_non_directive_mentions() {
3760 let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+I failed to compact exchange earlier.\n";
3761 assert!(!detect_exchange_compaction_request(diff));
3762 }
3763
3764 #[test]
3765 fn extract_required_response_blocks_multiple_prompts() {
3766 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,9 @@\n\
3767 ctx\n\
3768 +❯ First question?\n\
3769 +Context line.\n\
3770 +\n\
3771 +❯ Second question?\n\
3772 +do #n8q4. run tests. build + install. commit + push\n";
3773
3774 let blocks = extract_required_response_blocks(diff);
3775 assert_eq!(blocks.len(), 2);
3776 assert_eq!(blocks[0], "❯ First question?\nContext line.");
3777 assert_eq!(
3778 blocks[1],
3779 "❯ Second question?\ndo #n8q4. run tests. build + install. commit + push"
3780 );
3781 }
3782
3783 #[test]
3784 fn extract_required_response_blocks_preserves_code_fence_context() {
3785 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
3786 ctx\n\
3787 +❯ In src/boost-client, why did patchback miss the prefix?\n\
3788 +See my inquiry:\n\
3789 +```text\n\
3790 +line one\n\
3791 +line two\n\
3792 +```\n";
3793
3794 let blocks = extract_required_response_blocks(diff);
3795 assert_eq!(blocks.len(), 1);
3796 assert!(blocks[0].contains("❯ In src/boost-client"));
3797 assert!(blocks[0].contains("```text\nline one\nline two\n```"));
3798 }
3799
3800 #[test]
3801 fn format_required_response_targets_mentions_turn_completeness() {
3802 let diff =
3803 "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n+❯ Why were two prompts left unresolved?\n";
3804 let rendered = format_required_response_targets(diff).unwrap();
3805 assert!(rendered.contains("Do not stop at the newest question"));
3806 assert!(rendered.contains("<target index=\"1\">"));
3807 assert!(rendered.contains("❯ Why were two prompts left unresolved?"));
3808 }
3809
3810 #[test]
3811 fn format_prompt_bearing_changes_mentions_edit_and_artifact_contract() {
3812 let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n\
3813 context before\n\
3814 +❯ Why was this missed?\n\
3815 +\n\
3816 +This line should say 503.\n\
3817 context after\n";
3818 let rendered = format_prompt_bearing_changes(diff).unwrap();
3819 assert!(rendered.contains("User-authored prompt-bearing changes (oldest first):"));
3820 assert!(rendered.contains("kind=\"prompt_target\""));
3821 assert!(rendered.contains("kind=\"content_edit\""));
3822 assert!(rendered.contains("Treat `content_edit` items as user corrections"));
3823 }
3824
3825 #[test]
3826 fn prompt_prefix_normalization_targets_preserve_prompt_context_and_skip_fences() {
3827 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
3828 ctx\n\
3829 +❯ In src/boost-client, why did patchback miss the prefix?\n\
3830 +See my inquiry:\n\
3831 +- keep this markdown bullet bare\n\
3832 + - keep nested markdown bullets bare\n\
3833 +1. keep ordered markdown bullets bare\n\
3834 +```text\n\
3835 +line one\n\
3836 +line two\n\
3837 +```\n";
3838
3839 let targets = prompt_prefix_normalization_targets(diff);
3840 assert_eq!(
3841 targets,
3842 vec!["See my inquiry:".to_string(),],
3843 "only bare prompt-context prose should need fresh prefixing"
3844 );
3845 }
3846
3847 #[test]
3848 fn first_bare_prompt_prefix_target_detects_unprefixed_prompt_block_line() {
3849 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,5 @@\n\
3850 ctx\n\
3851 +❯ Existing question?\n\
3852 +Follow-up context.\n\
3853 +### Re: answer — gpt-5\n\
3854 +Body\n";
3855
3856 let bare = first_bare_prompt_prefix_target(diff);
3857 assert_eq!(bare.as_deref(), Some("Follow-up context."));
3858 }
3859
3860 #[test]
3861 fn first_bare_prompt_prefix_target_skips_markdown_lists() {
3862 let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,6 @@\n\
3863 ctx\n\
3864 +❯ Please compare these options:\n\
3865 +- option one\n\
3866 + - nested option detail\n\
3867 +1. ordered option\n\
3868 +### Re: answer — gpt-5\n";
3869
3870 let bare = first_bare_prompt_prefix_target(diff);
3871 assert_eq!(bare, None);
3872 }
3873
3874 fn pbc(kind: PromptBearingChangeKind, text: &str) -> PromptBearingChange {
3879 PromptBearingChange {
3880 kind,
3881 text: text.to_string(),
3882 }
3883 }
3884
3885 #[test]
3886 fn change_is_managed_state_only_accepts_queue_activity_toggle() {
3887 assert!(change_is_managed_state_only(&pbc(
3888 PromptBearingChangeKind::ContentEdit,
3889 "<!-- agent:queue auto -->"
3890 )));
3891 assert!(change_is_managed_state_only(&pbc(
3892 PromptBearingChangeKind::ContentEdit,
3893 "<!-- agent:queue -->"
3894 )));
3895 }
3896
3897 #[test]
3898 fn change_is_managed_state_only_accepts_frontmatter_queue_active_flip() {
3899 assert!(change_is_managed_state_only(&pbc(
3900 PromptBearingChangeKind::ContentEdit,
3901 "queue_active: true"
3902 )));
3903 assert!(change_is_managed_state_only(&pbc(
3904 PromptBearingChangeKind::ContentEdit,
3905 "queue_active: false"
3906 )));
3907 }
3908
3909 #[test]
3910 fn pipeline_only_frontmatter_write_is_no_change() {
3911 let snapshot = "---\nqueue: start\n---\n\n## Body\n- item\n";
3914 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";
3915 assert!(
3916 unified_diff_from_contents(snapshot, with_pipeline).is_none(),
3917 "adding a pipeline block must not register as a change"
3918 );
3919 assert!(
3920 unified_diff_from_contents(with_pipeline, snapshot).is_none(),
3921 "clearing a pipeline block must not register as a change"
3922 );
3923 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";
3925 assert!(
3926 unified_diff_from_contents(snapshot, with_pipeline_and_edit).is_some(),
3927 "a real body edit must still be detected through a pipeline write"
3928 );
3929 }
3930
3931 #[test]
3932 fn change_is_managed_state_only_accepts_pipeline_block_lines() {
3933 for line in [
3934 "agent_doc_pipeline:",
3935 " run_id: cycle-123",
3936 " step: write_applied",
3937 " turn_id: \"#x\"",
3938 " queue_task_id: \"#x\"",
3939 ] {
3940 assert!(
3941 change_is_managed_state_only(&pbc(PromptBearingChangeKind::ContentEdit, line)),
3942 "pipeline line should be managed state: {line:?}"
3943 );
3944 }
3945 }
3946
3947 #[test]
3948 fn change_is_managed_state_only_accepts_queue_item_lines() {
3949 assert!(change_is_managed_state_only(&pbc(
3950 PromptBearingChangeKind::PromptTarget,
3951 "- do [#newitem]"
3952 )));
3953 assert!(change_is_managed_state_only(&pbc(
3954 PromptBearingChangeKind::PromptTarget,
3955 "- ~do [#consumed]~"
3956 )));
3957 }
3958
3959 #[test]
3960 fn change_is_managed_state_only_accepts_backlog_and_done_item_lines() {
3961 assert!(change_is_managed_state_only(&pbc(
3962 PromptBearingChangeKind::ContentEdit,
3963 "- [ ] [#newitem] short description"
3964 )));
3965 assert!(change_is_managed_state_only(&pbc(
3966 PromptBearingChangeKind::ContentEdit,
3967 "- [/] [#gated] partial progress note"
3968 )));
3969 assert!(change_is_managed_state_only(&pbc(
3970 PromptBearingChangeKind::ContentEdit,
3971 "- 2026-05-25 [#done] closed last cycle"
3972 )));
3973 }
3974
3975 #[test]
3976 fn change_is_managed_state_only_accepts_multi_line_managed_blocks() {
3977 assert!(change_is_managed_state_only(&pbc(
3978 PromptBearingChangeKind::PromptTarget,
3979 "- do [#a]\n- do [#b]\n- do [#c]"
3980 )));
3981 }
3982
3983 #[test]
3984 fn change_is_managed_state_only_rejects_real_user_prompts() {
3985 assert!(!change_is_managed_state_only(&pbc(
3986 PromptBearingChangeKind::PromptTarget,
3987 "Why is the queue not auto-looping?"
3988 )));
3989 assert!(!change_is_managed_state_only(&pbc(
3990 PromptBearingChangeKind::PromptTarget,
3991 "❯ do this thing please"
3992 )));
3993 assert!(!change_is_managed_state_only(&pbc(
3994 PromptBearingChangeKind::ContentEdit,
3995 "Fix the regression on line 42."
3996 )));
3997 }
3998
3999 #[test]
4000 fn change_is_managed_state_only_rejects_mixed_managed_and_user_text() {
4001 assert!(!change_is_managed_state_only(&pbc(
4002 PromptBearingChangeKind::PromptTarget,
4003 "- do [#newitem]\nAnd please also fix the older bug."
4004 )));
4005 }
4006}