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