use serde::{Deserialize, Serialize};
use similar::{ChangeTag, TextDiff};
use crate::component;
const IMPERATIVE_LEADING_VERBS: &[&str] = &[
"add",
"audit",
"benchmark",
"build",
"check",
"clean",
"close",
"commit",
"document",
"fix",
"harden",
"implement",
"install",
"investigate",
"note",
"preserve",
"push",
"record",
"refactor",
"remove",
"repair",
"rerun",
"revisit",
"run",
"test",
"trace",
"update",
"verify",
"write",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffType {
Approval,
SimpleQuestion,
BoundaryArtifact,
Annotation,
StructuralChange,
MultiTopic,
ContentAddition,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffClassification {
pub diff_type: DiffType,
pub diff_type_reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptBearingChangeKind {
PromptTarget,
ContentEdit,
RecoveryArtifact,
BoundaryArtifact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptBearingChange {
pub kind: PromptBearingChangeKind,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PromptPrefixLine {
raw: String,
prefixed: bool,
}
pub fn strip_comments(content: &str) -> String {
crate::frontmatter::strip_pipeline_block_lines(&component::strip_comments(content))
}
pub fn annotate_diff(diff_text: &str) -> Option<String> {
let mut result = Vec::new();
let mut pending_removes: Vec<String> = Vec::new();
for line in diff_text.lines() {
if line.starts_with("--- ") || line.starts_with("+++ ") {
continue;
}
if line.starts_with("@@ ") {
flush_removes(&mut pending_removes, &mut result);
continue;
}
if let Some(content) = line.strip_prefix('+') {
if let Some(removed) = pending_removes.pop() {
let common = common_prefix_len(content, &removed);
let min_len = content.len().min(removed.len());
if min_len >= 3 && common > min_len * 6 / 10 {
result.push(format!("[user~] {}", content));
} else {
result.push(format!("[user-] {}", removed));
result.push(format!("[user+] {}", content));
}
} else {
result.push(format!("[user+] {}", content));
}
} else if let Some(content) = line.strip_prefix('-') {
pending_removes.push(content.to_string());
} else if let Some(content) = line.strip_prefix(' ') {
flush_removes(&mut pending_removes, &mut result);
result.push(format!("[agent] {}", content));
} else if !line.is_empty() {
flush_removes(&mut pending_removes, &mut result);
result.push(format!("[agent] {}", line));
}
}
flush_removes(&mut pending_removes, &mut result);
if result.is_empty() {
None
} else {
Some(result.join("\n"))
}
}
pub fn extract_inline_annotations(annotated_diff: &str) -> Vec<String> {
classify_prompt_bearing_changes_from_annotated_internal(annotated_diff, false)
.into_iter()
.filter_map(|change| match change.kind {
PromptBearingChangeKind::PromptTarget | PromptBearingChangeKind::ContentEdit => {
Some(change.text)
}
PromptBearingChangeKind::RecoveryArtifact
| PromptBearingChangeKind::BoundaryArtifact => None,
})
.collect()
}
fn is_substantive_agent_line(line: &str) -> bool {
let Some(content) = line.strip_prefix("[agent] ") else {
return false;
};
let t = content.trim_start();
!t.is_empty() && !t.starts_with("<!--") && !t.starts_with('#')
}
fn is_head_boundary_artifact(content: &str) -> bool {
content.ends_with(" (HEAD)") && {
let base = &content[..content.len() - " (HEAD)".len()];
base.trim_start_matches('#').starts_with(' ')
|| base.contains("### Re:")
|| base.contains("## ")
}
}
fn is_boundary_artifact_line(content: &str) -> bool {
let trimmed = content.trim();
trimmed.starts_with("<!-- agent:boundary:")
|| is_head_boundary_artifact(trimmed)
|| trimmed == "(HEAD)"
}
fn is_recovery_artifact_line(content: &str) -> bool {
let trimmed = content.trim_start();
trimmed.starts_with("### Re:")
|| trimmed.starts_with("#### Re:")
|| trimmed.starts_with("##### Re:")
|| trimmed == "## Assistant"
|| trimmed == "## User"
|| trimmed.starts_with("<!-- patch:")
|| trimmed.starts_with("<!-- /patch:")
|| line_is_binary_authored_ipc_proof_diagnostic(trimmed)
|| line_is_binary_authored_compact_summary(trimmed)
}
pub fn line_is_binary_authored_ipc_proof_diagnostic(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.starts_with("ipc_proof_insufficient")
&& trimmed.contains("invariant=")
&& trimmed.contains("recovery=")
{
return true;
}
if trimmed
== "This is binary-authored diagnostic content, not a user prompt, so it does not require a separate response cycle."
{
return true;
}
false
}
pub fn line_is_binary_authored_compact_summary(line: &str) -> bool {
let trimmed = line.trim();
let trimmed = trimmed
.strip_prefix("❯ ")
.or_else(|| trimmed.strip_prefix('❯'))
.map(str::trim_start)
.unwrap_or(trimmed);
trimmed == "### Session Summary"
|| trimmed == "Compacted content:"
|| trimmed.starts_with("*Compacted. Content archived to `")
|| (trimmed.starts_with("- Archived ") && trimmed.contains("response topic(s):"))
|| trimmed.starts_with("- Prior summary/context:")
|| trimmed.starts_with("- Trailing prompt/context:")
}
fn is_exchange_close_marker_line(content: &str) -> bool {
content.trim() == "<!-- /agent:exchange -->"
}
fn parse_slash_command_candidate(line: &str) -> Option<String> {
let trimmed = line.trim();
let rest = trimmed.strip_prefix('/')?;
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let token = &rest[..token_end];
if token.is_empty() {
return None;
}
let mut chars = token.chars();
let first = chars.next()?;
let command_like = first.is_ascii_lowercase()
&& chars
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, ':' | '_' | '-'));
command_like.then(|| trimmed.to_string())
}
fn line_looks_like_slash_command(line: &str) -> bool {
parse_slash_command_candidate(line).is_some()
}
fn line_looks_like_prompt_target(line: &str) -> bool {
let trimmed = line.trim();
let normalized_imperative = normalize_imperative_candidate(trimmed)
.is_some_and(|normalized| looks_like_imperative_directive(&normalized));
let slash_command = line_looks_like_slash_command(trimmed);
let prompt_prefixed = trimmed.starts_with('❯') && !line_looks_like_markdown_list_item(trimmed);
!trimmed.is_empty()
&& !trimmed.starts_with("<!--")
&& !trimmed.starts_with("```")
&& !trimmed.starts_with("~~~")
&& !trimmed.starts_with("### Re:")
&& !line_has_known_response_label_after_prompt(trimmed)
&& (slash_command || prompt_prefixed || trimmed.ends_with('?') || normalized_imperative)
}
pub fn text_line_looks_like_prompt_target(line: &str) -> bool {
line_looks_like_prompt_target(line)
}
fn block_looks_like_prompt_target(block: &str) -> bool {
block.lines().any(line_looks_like_prompt_target)
}
fn is_exchange_response_heading(trimmed: &str) -> bool {
trimmed == "## Assistant"
|| trimmed.starts_with("### Re:")
|| trimmed.starts_with("#### Re:")
|| trimmed.starts_with("##### Re:")
|| trimmed.starts_with("###### Re:")
}
fn normalized_prompt_preview_line(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || !text_line_looks_like_prompt_target(trimmed) {
return None;
}
Some(trimmed.trim_start_matches('❯').trim().to_string())
}
pub fn line_looks_like_fresh_prompt_after_response(trimmed: &str) -> bool {
if line_looks_like_plain_response_after_prompt(trimmed) {
return false;
}
let unprefixed = trimmed.trim_start_matches('❯').trim();
let lower = unprefixed.to_ascii_lowercase();
trimmed.starts_with('❯')
|| unprefixed.ends_with('?')
|| line_looks_like_slash_command(unprefixed)
|| lower == "go"
|| lower == "continue"
|| lower.starts_with("do #")
|| lower.starts_with("do [#")
|| lower.starts_with("fix #")
|| lower.starts_with("run ")
|| lower.starts_with("rerun ")
|| lower.starts_with("build ")
|| lower.starts_with("test ")
|| lower.starts_with("commit ")
|| lower.starts_with("push ")
|| lower.starts_with("verify ")
|| lower.starts_with("investigate ")
}
pub(crate) fn line_looks_like_soft_prompt_request(trimmed: &str) -> bool {
let lower = trimmed.to_ascii_lowercase();
lower.starts_with("please ")
|| lower.contains(" please ")
|| lower.starts_with("can you ")
|| lower.starts_with("could you ")
|| lower.starts_with("would you ")
|| lower.starts_with("need you to ")
}
pub fn line_looks_like_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
let unprefixed = trimmed
.strip_prefix("❯ ")
.or_else(|| trimmed.strip_prefix('❯'))
.map(str::trim_start)
.unwrap_or(trimmed);
if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
return false;
}
is_target
|| line_looks_like_fresh_prompt_after_response(unprefixed)
|| line_looks_like_soft_prompt_request(unprefixed)
}
pub fn line_looks_like_targeted_prompt_prefix_repair_start(trimmed: &str, is_target: bool) -> bool {
if !is_target {
return false;
}
let unprefixed = trimmed
.strip_prefix("❯ ")
.or_else(|| trimmed.strip_prefix('❯'))
.map(str::trim_start)
.unwrap_or(trimmed);
if unprefixed.is_empty() || line_looks_like_plain_response_after_prompt(unprefixed) {
return false;
}
if (trimmed.starts_with('❯') && !line_looks_like_markdown_list_item(trimmed))
|| line_looks_like_soft_prompt_request(unprefixed)
{
return true;
}
let lower = unprefixed.to_ascii_lowercase();
lower == "go"
|| lower == "continue"
|| line_looks_like_slash_command(unprefixed)
|| lower.starts_with("do #")
|| lower.starts_with("do [#")
|| lower.starts_with("fix #")
|| lower.starts_with("run ")
|| lower.starts_with("rerun ")
|| lower.starts_with("build ")
|| lower.starts_with("test ")
|| lower.starts_with("commit ")
|| lower.starts_with("push ")
|| lower.starts_with("verify ")
|| lower.starts_with("investigate ")
}
pub fn line_looks_like_plain_response_after_prompt(trimmed: &str) -> bool {
if trimmed.is_empty() {
return false;
}
if line_has_known_response_label_after_prompt(trimmed) {
return true;
}
if normalized_prompt_preview_line(trimmed).is_some() {
return false;
}
if line_looks_like_markdown_list_item(trimmed)
|| trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("Plan:")
|| trimmed.starts_with("Verification")
|| trimmed.starts_with("What changed:")
|| trimmed.starts_with("Follow-up:")
|| trimmed.starts_with("Backlog:")
|| trimmed.starts_with("`#")
{
return true;
}
let lower = trimmed.to_ascii_lowercase();
lower.starts_with("i updated ")
|| lower.starts_with("i fixed ")
|| lower.starts_with("i added ")
|| lower.starts_with("i implemented ")
|| lower.starts_with("i left ")
|| lower.starts_with("i dry-ran ")
|| lower.starts_with("recovered ")
|| lower.starts_with("confirmed ")
|| lower.starts_with("no code files changed")
|| lower.starts_with("this closeout ")
|| lower.starts_with("updated ")
|| lower.starts_with("fixed ")
|| lower.starts_with("added ")
|| lower.starts_with("implemented ")
}
fn line_has_known_response_label_after_prompt(line: &str) -> bool {
let Some(normalized) = normalize_response_label_candidate(line) else {
return false;
};
matches!(
normalized.as_str(),
s if s.starts_with("Plan:")
|| s.starts_with("Verification:")
|| s.starts_with("What changed:")
|| s.starts_with("Follow-up:")
|| s.starts_with("Commit / push:")
|| s.starts_with("Backlog:")
)
}
fn normalize_response_label_candidate(line: &str) -> Option<String> {
let mut trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix('❯') {
trimmed = rest.trim_start();
}
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
{
trimmed = rest.trim_start();
} else {
let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
if digits > 0 {
let rest = &trimmed[digits..];
if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
trimmed = rest.trim_start();
}
}
}
let stripped = strip_markdown_emphasis_pair(trimmed);
let normalized = stripped.trim_start();
(!normalized.is_empty()).then(|| normalized.to_string())
}
fn strip_markdown_emphasis_pair(text: &str) -> String {
for marker in ["***", "___", "**", "__", "*", "_"] {
if let Some(rest) = text.strip_prefix(marker)
&& let Some(end) = rest.find(marker)
{
let label = &rest[..end];
let tail = &rest[end + marker.len()..];
if !label.trim().is_empty()
&& (label.trim_end().ends_with(':') || tail.trim_start().starts_with(':'))
{
return format!("{}{}", label, tail);
}
}
}
text.to_string()
}
pub fn prompt_change_is_already_answered(change_text: &str) -> bool {
fn fence_open(trimmed: &str) -> Option<(char, usize)> {
let fc = trimmed.chars().next()?;
if fc != '`' && fc != '~' {
return None;
}
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 { Some((fc, fl)) } else { None }
}
fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc != fence_char {
return false;
}
let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
fl >= fence_len && trimmed[fl..].trim().is_empty()
}
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
let mut saw_prompt = false;
let mut saw_response = false;
for segment in change_text.split_inclusive('\n') {
let line = segment.trim_end_matches('\n');
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("<!--") {
continue;
}
if !in_fence {
if let Some((fc, fl)) = fence_open(trimmed) {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
} else {
if fence_close(trimmed, fence_char, fence_len) {
in_fence = false;
}
continue;
}
if is_exchange_response_heading(trimmed) {
if saw_prompt {
saw_response = true;
}
continue;
}
if normalized_prompt_preview_line(trimmed).is_some() {
if saw_response && line_looks_like_fresh_prompt_after_response(trimmed) {
return false;
}
saw_prompt = true;
continue;
}
if !saw_prompt && line_looks_like_soft_prompt_request(trimmed) {
saw_prompt = true;
continue;
}
if saw_prompt && line_looks_like_plain_response_after_prompt(trimmed) {
saw_response = true;
}
}
saw_prompt && saw_response
}
pub fn prompt_change_is_answered_by_later_response(
changes: &[PromptBearingChange],
idx: usize,
) -> bool {
if changes
.get(idx)
.is_none_or(|change| change.kind != PromptBearingChangeKind::PromptTarget)
{
return false;
}
for later in changes.iter().skip(idx + 1) {
match later.kind {
PromptBearingChangeKind::PromptTarget => return false,
PromptBearingChangeKind::RecoveryArtifact
| PromptBearingChangeKind::BoundaryArtifact => {
let heading = later
.text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or(later.text.as_str())
.trim();
if is_exchange_response_heading(heading) {
return true;
}
}
PromptBearingChangeKind::ContentEdit => {}
}
}
false
}
fn suppress_answered_prompt_runs(changes: Vec<PromptBearingChange>) -> Vec<PromptBearingChange> {
let mut filtered = Vec::with_capacity(changes.len());
let mut skip_answered_response_run = false;
for (idx, change) in changes.iter().enumerate() {
match change.kind {
PromptBearingChangeKind::RecoveryArtifact
| PromptBearingChangeKind::BoundaryArtifact => {
filtered.push(change.clone());
}
PromptBearingChangeKind::PromptTarget => {
if skip_answered_response_run {
let preview = change
.text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or(change.text.as_str())
.trim();
if !line_looks_like_fresh_prompt_after_response(preview) {
continue;
}
}
if prompt_change_is_already_answered(&change.text)
|| prompt_change_is_answered_by_later_response(&changes, idx)
{
skip_answered_response_run = true;
continue;
}
filtered.push(change.clone());
}
PromptBearingChangeKind::ContentEdit => {
if skip_answered_response_run {
continue;
}
filtered.push(change.clone());
}
}
}
filtered
}
fn classify_prompt_bearing_block(
block_text: &str,
has_substantive_agent_after: bool,
closes_exchange_tail: bool,
) -> Option<PromptBearingChangeKind> {
let trimmed = block_text.trim();
if trimmed.is_empty() {
return None;
}
let non_blank: Vec<&str> = trimmed
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
if non_blank.is_empty() {
return None;
}
if non_blank.iter().all(|line| is_boundary_artifact_line(line)) {
return Some(PromptBearingChangeKind::BoundaryArtifact);
}
if non_blank
.first()
.is_some_and(|line| is_recovery_artifact_line(line))
{
return Some(PromptBearingChangeKind::RecoveryArtifact);
}
if non_blank
.iter()
.any(|line| line_is_binary_authored_ipc_proof_diagnostic(line))
{
return Some(PromptBearingChangeKind::RecoveryArtifact);
}
if non_blank
.iter()
.all(|line| line_is_binary_authored_compact_summary(line))
{
return Some(PromptBearingChangeKind::RecoveryArtifact);
}
if block_looks_like_prompt_target(trimmed) {
return Some(PromptBearingChangeKind::PromptTarget);
}
if closes_exchange_tail
&& non_blank
.iter()
.all(|line| line_looks_like_plain_response_after_prompt(line))
{
return None;
}
if closes_exchange_tail {
return Some(PromptBearingChangeKind::PromptTarget);
}
if has_substantive_agent_after {
return Some(PromptBearingChangeKind::ContentEdit);
}
None
}
pub fn prompt_prefix_normalization_targets(diff: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::<String>::new();
let mut lines = Vec::new();
for change in classify_prompt_bearing_changes_raw(diff) {
if change.kind != PromptBearingChangeKind::PromptTarget {
continue;
}
for line in prompt_prefix_lines_from_block(&change.text) {
if line.prefixed {
continue;
}
if seen.insert(line.raw.clone()) {
lines.push(line.raw);
}
}
}
lines
}
pub fn first_bare_prompt_prefix_target(diff: &str) -> Option<String> {
for change in classify_prompt_bearing_changes_raw(diff) {
if change.kind != PromptBearingChangeKind::PromptTarget {
continue;
}
for line in prompt_prefix_lines_from_block(&change.text) {
if !line.prefixed {
return Some(line.raw);
}
}
}
None
}
fn classify_prompt_bearing_changes_from_annotated(
annotated_diff: &str,
) -> Vec<PromptBearingChange> {
classify_prompt_bearing_changes_from_annotated_internal(annotated_diff, true)
}
fn classify_prompt_bearing_changes_from_annotated_internal(
annotated_diff: &str,
promote_exchange_tail_prompts: bool,
) -> Vec<PromptBearingChange> {
fn fence_open(trimmed: &str) -> Option<(char, usize)> {
let fc = trimmed.chars().next()?;
if fc != '`' && fc != '~' {
return None;
}
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 { Some((fc, fl)) } else { None }
}
fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc != fence_char {
return false;
}
let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
fl >= fence_len && trimmed[fl..].trim().is_empty()
}
let lines: Vec<&str> = annotated_diff.lines().collect();
let mut changes = Vec::new();
let mut i = 0usize;
while i < lines.len() {
let Some(mut block) = lines[i]
.strip_prefix("[user+] ")
.or_else(|| lines[i].strip_prefix("[user~] "))
.map(|line| vec![line.to_string()])
else {
i += 1;
continue;
};
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 3usize;
let first_trimmed = block[0].trim();
if let Some((fc, fl)) = fence_open(first_trimmed) {
in_fence = true;
fence_char = fc;
fence_len = fl;
}
i += 1;
while i < lines.len() {
if let Some(content) = lines[i]
.strip_prefix("[user+] ")
.or_else(|| lines[i].strip_prefix("[user~] "))
{
let trimmed = content.trim();
let starts_new_block = !in_fence
&& block.last().is_some_and(|line| line.trim().is_empty())
&& !trimmed.is_empty()
&& !trimmed.starts_with("```")
&& !trimmed.starts_with("~~~");
if starts_new_block {
break;
}
block.push(content.to_string());
if !in_fence {
if let Some((fc, fl)) = fence_open(trimmed) {
in_fence = true;
fence_char = fc;
fence_len = fl;
}
} else if fence_close(trimmed, fence_char, fence_len) {
in_fence = false;
}
i += 1;
} else {
break;
}
}
while block.first().is_some_and(|line| line.trim().is_empty()) {
block.remove(0);
}
while block.last().is_some_and(|line| line.trim().is_empty()) {
block.pop();
}
if block.is_empty() {
continue;
}
let has_substantive_agent_after = lines[i..]
.iter()
.any(|line| is_substantive_agent_line(line));
let closes_exchange_tail = lines[i..]
.iter()
.find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
line.strip_prefix("[agent] ")
})
.is_some_and(is_exchange_close_marker_line);
let text = block.join("\n");
let Some(kind) = classify_prompt_bearing_block(
&text,
has_substantive_agent_after,
promote_exchange_tail_prompts && closes_exchange_tail,
) else {
continue;
};
changes.push(PromptBearingChange { kind, text });
}
changes
}
fn classify_prompt_bearing_changes_raw(diff: &str) -> Vec<PromptBearingChange> {
let mut changes = annotate_diff(diff)
.map(|annotated| classify_prompt_bearing_changes_from_annotated(&annotated))
.unwrap_or_default();
for text in extract_prompt_target_blocks(diff) {
if changes
.iter()
.any(|existing| prompt_target_block_already_classified(existing, &text))
{
continue;
}
changes.push(PromptBearingChange {
kind: PromptBearingChangeKind::PromptTarget,
text,
});
}
changes
}
fn prompt_target_block_already_classified(
existing: &PromptBearingChange,
prompt_text: &str,
) -> bool {
if existing.text == prompt_text {
return true;
}
match existing.kind {
PromptBearingChangeKind::PromptTarget => {
(existing.text.contains(prompt_text) || prompt_text.contains(&existing.text))
&& prompt_change_is_already_answered(&existing.text)
}
PromptBearingChangeKind::RecoveryArtifact
| PromptBearingChangeKind::BoundaryArtifact
| PromptBearingChangeKind::ContentEdit => existing.text.contains(prompt_text),
}
}
pub fn classify_prompt_bearing_changes(diff: &str) -> Vec<PromptBearingChange> {
suppress_answered_prompt_runs(classify_prompt_bearing_changes_raw(diff))
}
pub fn change_is_managed_state_only(change: &PromptBearingChange) -> bool {
text_is_managed_state_only(&change.text)
}
fn text_is_managed_state_only(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
let lines: Vec<&str> = trimmed.lines().collect();
if lines.is_empty() {
return false;
}
lines.iter().all(|line| line_is_managed_state_only(line))
}
fn line_is_managed_state_only(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.starts_with("<!-- agent:queue")
|| trimmed.starts_with("<!-- /agent:queue")
|| trimmed.starts_with("<!-- agent:backlog")
|| trimmed.starts_with("<!-- /agent:backlog")
|| trimmed.starts_with("<!-- agent:done")
|| trimmed.starts_with("<!-- /agent:done")
|| trimmed.starts_with("<!-- agent:review")
|| trimmed.starts_with("<!-- /agent:review")
{
return true;
}
if trimmed.starts_with("queue_active:") {
return true;
}
if trimmed.starts_with("agent_doc_pipeline:")
|| trimmed.starts_with("run_id:")
|| trimmed.starts_with("step:")
|| trimmed.starts_with("turn_id:")
|| trimmed.starts_with("queue_task_id:")
{
return true;
}
if trimmed.starts_with("- do ") || trimmed.starts_with("- ~do ") {
return true;
}
if trimmed.starts_with("- [ ]")
|| trimmed.starts_with("- [/]")
|| trimmed.starts_with("- [x]")
|| trimmed.starts_with("- [-]")
|| trimmed.starts_with("- [?]")
{
return true;
}
if trimmed.len() >= 12
&& trimmed.starts_with("- ")
&& trimmed.chars().nth(2).is_some_and(|c| c.is_ascii_digit())
{
return true;
}
if trimmed.starts_with("preset #") || trimmed.starts_with("preset:") {
return true;
}
false
}
pub fn suppress_inactive_queue_additions(diff: &str, current_content: &str) -> String {
let ranges = queue_line_ranges(current_content);
if ranges.is_empty() {
return diff.to_string();
}
let mut current_line: Option<usize> = None;
let mut filtered = String::new();
for line in diff.lines() {
if line.starts_with("@@ ") {
current_line = hunk_current_start_line(line);
filtered.push_str(line);
filtered.push('\n');
continue;
}
if line.starts_with("--- ") || line.starts_with("+++ ") {
filtered.push_str(line);
filtered.push('\n');
continue;
}
if line.starts_with('+') && !line.starts_with("+++") {
let line_no = current_line.unwrap_or(0);
if current_line_is_in_ranges(line_no, &ranges) {
current_line = current_line.map(|n| n + 1);
continue;
}
current_line = current_line.map(|n| n + 1);
filtered.push_str(line);
filtered.push('\n');
continue;
}
if line.starts_with(' ') {
current_line = current_line.map(|n| n + 1);
}
filtered.push_str(line);
filtered.push('\n');
}
filtered
}
fn queue_line_ranges(content: &str) -> Vec<(usize, usize)> {
let Ok(components) = component::parse(content) else {
return Vec::new();
};
components
.iter()
.filter(|component| component.name == "queue")
.map(|component| {
(
line_number_at_byte(content, component.open_start),
line_number_at_byte(content, component.close_end.saturating_sub(1)),
)
})
.collect()
}
fn line_number_at_byte(content: &str, byte_offset: usize) -> usize {
let end = byte_offset.min(content.len());
content[..end].bytes().filter(|b| *b == b'\n').count() + 1
}
fn hunk_current_start_line(header: &str) -> Option<usize> {
let plus = header
.split_whitespace()
.find(|part| part.starts_with('+'))?;
let number = plus
.trim_start_matches('+')
.split(',')
.next()
.unwrap_or_default();
number.parse::<usize>().ok()
}
fn current_line_is_in_ranges(line_no: usize, ranges: &[(usize, usize)]) -> bool {
ranges
.iter()
.any(|(start, end)| line_no >= *start && line_no <= *end)
}
fn prompt_prefix_lines_from_block(block: &str) -> Vec<PromptPrefixLine> {
fn fence_open(trimmed: &str) -> Option<(char, usize)> {
let fc = trimmed.chars().next()?;
if fc != '`' && fc != '~' {
return None;
}
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 { Some((fc, fl)) } else { None }
}
fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc != fence_char {
return false;
}
let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
fl >= fence_len && trimmed[fl..].trim().is_empty()
}
let mut lines = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 3usize;
let mut in_response_block = false;
for line in block.lines() {
let trimmed = line.trim();
if !in_fence {
if let Some((fc, fl)) = fence_open(trimmed) {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
} else if fence_close(trimmed, fence_char, fence_len) {
in_fence = false;
continue;
}
if in_fence || trimmed.is_empty() || trimmed.starts_with("<!--") {
continue;
}
if line_looks_like_markdown_list_item(trimmed) {
continue;
}
if is_exchange_response_heading(trimmed) {
in_response_block = true;
continue;
}
if in_response_block {
if line_looks_like_targeted_prompt_prefix_repair_start(trimmed, true) {
in_response_block = false;
} else {
continue;
}
}
lines.push(PromptPrefixLine {
raw: line.to_string(),
prefixed: trimmed.starts_with('❯'),
});
}
lines
}
fn extract_prompt_target_blocks(diff: &str) -> Vec<String> {
fn is_response_heading(line: &str) -> bool {
line.starts_with("### Re:") || line.starts_with("#### Re:") || line.starts_with("## Re:")
}
fn fence_open(trimmed: &str) -> Option<(char, usize)> {
let fc = trimmed.chars().next()?;
if fc != '`' && fc != '~' {
return None;
}
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 { Some((fc, fl)) } else { None }
}
fn fence_close(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc != fence_char {
return false;
}
let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
fl >= fence_len && trimmed[fl..].trim().is_empty()
}
fn trim_block_lines(lines: &mut Vec<String>) {
while lines.first().is_some_and(|line| line.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
}
fn block_start(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with("<!--")
|| trimmed.starts_with("```")
|| trimmed.starts_with("~~~")
|| is_response_heading(trimmed)
{
return false;
}
line_looks_like_prompt_target(trimmed)
}
fn flush(blocks: &mut Vec<String>, current: &mut Vec<String>) {
trim_block_lines(current);
if !current.is_empty() {
blocks.push(current.join("\n"));
current.clear();
}
}
let mut blocks = Vec::new();
let mut current: Vec<String> = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 3usize;
for line in diff.lines() {
if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
if !in_fence {
flush(&mut blocks, &mut current);
}
continue;
}
let Some(content) = line.strip_prefix('+') else {
if !in_fence {
flush(&mut blocks, &mut current);
}
continue;
};
let trimmed = content.trim();
let starts_new_block = !current.is_empty()
&& !in_fence
&& ((current.last().is_some_and(|line| line.trim().is_empty()) && !trimmed.is_empty())
|| trimmed.starts_with('❯'));
if starts_new_block && current.iter().any(|line| !line.trim().is_empty()) {
flush(&mut blocks, &mut current);
}
if current.is_empty() && !block_start(content) {
continue;
}
current.push(content.to_string());
if !in_fence {
if let Some((fc, fl)) = fence_open(trimmed) {
in_fence = true;
fence_char = fc;
fence_len = fl;
}
} else if fence_close(trimmed, fence_char, fence_len) {
in_fence = false;
}
}
flush(&mut blocks, &mut current);
blocks
}
fn flush_removes(pending: &mut Vec<String>, result: &mut Vec<String>) {
for removed in pending.drain(..) {
result.push(format!("[user-] {}", removed));
}
}
fn common_prefix_len(a: &str, b: &str) -> usize {
a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
}
const BUILTIN_COMMAND_NAMES: &[&str] = &[
"/help",
"/model",
"/clear",
"/compact",
"/cost",
"/login",
"/logout",
"/status",
"/config",
"/memory",
"/review",
"/bug",
"/fast",
"/slow",
"/permissions",
"/terminal-setup",
"/doctor",
"/init",
"/pr-comments",
"/vim",
"/diff",
"/undo",
"/resume",
"/listen",
"/mcp",
"/approved-tools",
"/add-dir",
"/release-notes",
"/hooks",
"/btw",
];
pub fn is_builtin_command(cmd: &str) -> bool {
let cmd_name = cmd.split_whitespace().next().unwrap_or("");
BUILTIN_COMMAND_NAMES.contains(&cmd_name)
}
pub struct ParsedSlashCommands {
pub skill_commands: Vec<String>,
pub builtin_commands: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct OrchestrationRequest {
pub mode: OrchestrationRequestMode,
pub trigger_text: String,
pub task_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrchestrationRequestMode {
Sequential,
Parallel,
Dag,
}
pub fn detect_prompt_preset_requests(diff: &str) -> Vec<String> {
let mut requests = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
continue;
}
let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
&line[1..]
} else {
line
};
let trimmed = content.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
}
}
continue;
}
if !line.starts_with('+') || line.starts_with("+++") {
continue;
}
if content.starts_with('>') {
continue;
}
collect_prompt_preset_requests_from_line(content, &mut requests);
}
requests
}
pub fn extract_prompt_preset_requests_from_text(text: &str) -> Vec<String> {
let mut requests = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in text.lines() {
let trimmed = line.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
}
}
continue;
}
if trimmed.starts_with('>') {
continue;
}
collect_prompt_preset_requests_from_line(line, &mut requests);
}
requests
}
fn collect_prompt_preset_requests_from_line(line: &str, requests: &mut Vec<String>) {
let Some(names) = parse_prompt_preset_directive(line) else {
return;
};
for name in names {
if !requests.iter().any(|existing| existing == &name) {
requests.push(name);
}
}
}
fn parse_prompt_preset_directive(line: &str) -> Option<Vec<String>> {
let mut trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix('❯') {
trimmed = rest.trim_start();
}
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
{
trimmed = rest.trim_start();
} else {
let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
if digits > 0 {
let rest = &trimmed[digits..];
if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
trimmed = rest.trim_start();
}
}
}
let lower = trimmed.to_ascii_lowercase();
let rest = if lower.starts_with("presets ") {
&trimmed["presets ".len()..]
} else if lower.starts_with("preset ") {
&trimmed["preset ".len()..]
} else {
return None;
};
let rest = rest.trim();
if rest.is_empty() {
return None;
}
let mut names = Vec::new();
for segment in rest.split(',') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
for part in segment.split(" and ") {
let name = part.trim().trim_end_matches(['.', ':', ';']).trim();
if !name.is_empty() {
names.push(name.to_string());
}
}
}
(!names.is_empty()).then_some(names)
}
pub fn parse_slash_commands_classified(diff: &str) -> ParsedSlashCommands {
let commands = parse_slash_commands(diff);
let mut skill_commands = Vec::new();
let mut builtin_commands = Vec::new();
for cmd in commands {
if is_builtin_command(&cmd) {
builtin_commands.push(cmd);
} else {
skill_commands.push(cmd);
}
}
ParsedSlashCommands {
skill_commands,
builtin_commands,
}
}
pub fn parse_slash_command_only_added_diff(diff: &str) -> Option<Vec<String>> {
let mut commands = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
continue;
}
let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
&line[1..]
} else {
line
};
let trimmed = content.trim_start();
let was_in_fence = in_fence;
let mut fence_delimiter = false;
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
fence_delimiter = true;
}
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fence_char).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
fence_delimiter = true;
}
}
}
if !line.starts_with('+') || line.starts_with("+++") {
continue;
}
if content.trim().is_empty() {
continue;
}
if was_in_fence || fence_delimiter || trimmed.starts_with('>') {
return None;
}
let command = parse_slash_command_candidate(content)?;
commands.push(command);
}
(!commands.is_empty()).then_some(commands)
}
pub fn parse_slash_commands(diff: &str) -> Vec<String> {
let mut commands = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
continue;
}
let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
&line[1..]
} else {
line
};
let trimmed = content.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue; }
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
continue; }
}
}
if !line.starts_with('+') || line.starts_with("+++") {
continue;
}
if in_fence {
continue;
}
if content.trim_start().starts_with('>') {
continue;
}
if let Some(command) = parse_slash_command_candidate(content) {
commands.push(command);
}
}
commands
}
pub fn detect_orchestration_request(diff: &str) -> Option<OrchestrationRequest> {
for block in collect_added_text_blocks(diff) {
let task_count = block
.iter()
.filter(|line| parse_markdown_list_item(line).is_some())
.count();
if task_count < 2 {
continue;
}
let trigger_lines: Vec<&str> = block
.iter()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && parse_markdown_list_item(line).is_none())
.collect();
let trigger_text = if trigger_lines.is_empty() {
block.join(" ")
} else {
trigger_lines.join(" ")
};
let Some(mode) = detect_orchestration_mode(&trigger_text) else {
continue;
};
return Some(OrchestrationRequest {
mode,
trigger_text,
task_count,
});
}
None
}
pub fn detect_queue_trigger(diff: &str) -> bool {
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
continue;
}
if !line.starts_with('+') {
continue;
}
let content = &line[1..];
let trimmed = content.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len {
in_fence = false;
}
}
continue;
}
if trimmed.starts_with('>') {
continue;
}
let text = trimmed.strip_prefix("❯ ").unwrap_or(trimmed);
let lower = text.to_lowercase();
if lower.starts_with("do queue") || lower.starts_with("run queue") {
let after = if lower.starts_with("do queue") {
&text[8..]
} else {
&text[9..]
};
if after.is_empty() || after.starts_with(|c: char| !c.is_alphanumeric() && c != '#') {
return true;
}
}
}
false
}
pub fn unified_diff_from_contents(previous: &str, current: &str) -> Option<String> {
let previous_stripped = strip_comments(previous);
let current_stripped = strip_comments(current);
let diff = TextDiff::from_lines(&previous_stripped, ¤t_stripped);
let has_changes = diff.iter_all_changes().any(|c| c.tag() != ChangeTag::Equal);
if !has_changes {
return None;
}
Some(
diff.unified_diff()
.context_radius(5)
.header("snapshot", "document")
.to_string(),
)
}
pub fn synthetic_added_lines_diff(body: &str, target: &str) -> String {
let lines = body.lines().collect::<Vec<_>>();
let count = lines.len().max(1);
let mut diff = format!("--- snapshot\n+++ {target}\n@@ -0,0 +1,{count} @@\n");
if lines.is_empty() {
diff.push_str("+\n");
return diff;
}
for line in lines {
diff.push('+');
diff.push_str(line);
diff.push('\n');
}
diff
}
pub fn extract_imperative_directives(diff: &str) -> Vec<String> {
let mut directives = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
continue;
}
let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
&line[1..]
} else {
line
};
let trimmed = content.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
continue;
}
}
}
if !line.starts_with('+') || line.starts_with("+++") || in_fence {
continue;
}
if content.starts_with('>') {
continue;
}
let Some(normalized) = normalize_imperative_candidate(content) else {
continue;
};
if looks_like_imperative_directive(&normalized) {
directives.push(normalized);
}
}
directives
}
pub fn diff_contains_imperative_directive(diff: &str) -> bool {
if !extract_imperative_directives(diff).is_empty() {
return true;
}
matches!(classify_diff(diff).diff_type, DiffType::Approval)
}
fn trimmed_is_markdown_list_item(trimmed: &str) -> bool {
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
return true;
}
if let Some(dot) = trimmed.find(". ") {
let head = &trimmed[..dot];
if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
false
}
pub fn detect_exchange_compaction_request(diff: &str) -> bool {
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
continue;
}
let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
&line[1..]
} else {
line
};
let trimmed = content.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
continue;
}
}
}
if !line.starts_with('+')
|| line.starts_with("+++")
|| in_fence
|| content.starts_with('>')
|| trimmed_is_markdown_list_item(trimmed)
{
continue;
}
let Some(normalized) = normalize_imperative_candidate(content) else {
continue;
};
let lower = normalized.to_ascii_lowercase();
if lower.starts_with("compact exchange") || lower.starts_with("compact the exchange") {
return true;
}
}
false
}
#[allow(dead_code)]
pub fn extract_required_response_blocks(diff: &str) -> Vec<String> {
extract_prompt_target_blocks(diff)
}
#[allow(dead_code)]
pub fn format_required_response_targets(diff: &str) -> Option<String> {
let blocks = extract_required_response_blocks(diff);
if blocks.is_empty() {
return None;
}
let mut out = String::from(
"Required response targets (oldest first):\n\
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",
);
for (idx, block) in blocks.iter().enumerate() {
out.push_str(&format!(
"<target index=\"{}\">\n{}\n</target>\n\n",
idx + 1,
block
));
}
Some(out)
}
pub fn format_prompt_bearing_changes(diff: &str) -> Option<String> {
let changes = classify_prompt_bearing_changes(diff);
if changes.is_empty() {
return None;
}
let mut out = String::from(
"User-authored prompt-bearing changes (oldest first):\n\
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\
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",
);
for (idx, change) in changes.iter().enumerate() {
out.push_str(&format!(
"<change index=\"{}\" kind=\"{}\">\n{}\n</change>\n\n",
idx + 1,
serde_json::to_string(&change.kind)
.unwrap_or_else(|_| "\"prompt_target\"".to_string())
.trim_matches('"'),
change.text
));
}
Some(out)
}
fn looks_like_imperative_directive(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
let compact = lower.trim();
let exact = compact.trim_end_matches(|c: char| c.is_ascii_punctuation());
if APPROVAL_WORDS.contains(&exact) {
return true;
}
compact.starts_with("do #")
|| compact.starts_with("do [#")
|| compact.starts_with("fix #")
|| compact.starts_with("fix this")
|| compact.contains(" run tests")
|| compact.starts_with("run tests")
|| compact.contains(" run benchmarks")
|| compact.starts_with("run benchmarks")
|| compact.contains(" build + install")
|| compact.starts_with("build + install")
|| compact.contains(" build and install")
|| compact.starts_with("build and install")
|| compact.contains(" commit + push")
|| compact.starts_with("commit + push")
|| compact.contains(" commit and push")
|| compact.starts_with("commit and push")
|| starts_with_imperative_verb(compact)
}
fn normalize_imperative_candidate(line: &str) -> Option<String> {
let mut trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix("- ") {
trimmed = rest.trim_start();
}
trimmed = strip_pending_checkbox_prefix(trimmed);
if let Some(rest) = trimmed.strip_prefix("[#")
&& let Some(close) = rest.find(']')
{
let id = &rest[..close];
if crate::pending::is_valid_pending_id(id) {
trimmed = rest[close + 1..].trim_start();
}
}
let normalized = trimmed
.trim_start_matches('❯')
.trim_start()
.trim_end_matches(|c: char| c.is_ascii_punctuation() && c != ']')
.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
fn collect_added_text_blocks(diff: &str) -> Vec<Vec<String>> {
let mut blocks = Vec::new();
let mut current = Vec::new();
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 0usize;
let mut in_recovery_response_tail = false;
for line in diff.lines() {
if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
if !current.is_empty() {
blocks.push(std::mem::take(&mut current));
}
in_recovery_response_tail = false;
continue;
}
let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
&line[1..]
} else {
line
};
let trimmed = content.trim_start();
if !in_fence {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == '`' || fc == '~' {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= 3 {
in_fence = true;
fence_char = fc;
fence_len = fl;
continue;
}
}
} else {
let fc = trimmed.chars().next().unwrap_or('\0');
if fc == fence_char {
let fl = trimmed.chars().take_while(|&c| c == fc).count();
if fl >= fence_len && trimmed[fl..].trim().is_empty() {
in_fence = false;
}
}
continue;
}
if !line.starts_with('+') || line.starts_with("+++") {
if !current.is_empty() {
blocks.push(std::mem::take(&mut current));
}
in_recovery_response_tail = false;
continue;
}
if content.starts_with('>') {
continue;
}
if is_exchange_response_heading(trimmed) {
if !current.is_empty() {
blocks.push(std::mem::take(&mut current));
}
in_recovery_response_tail = true;
continue;
}
if in_recovery_response_tail {
if line_looks_like_targeted_prompt_prefix_repair_start(
trimmed,
line_looks_like_prompt_target(trimmed),
) {
in_recovery_response_tail = false;
} else {
continue;
}
}
current.push(content.trim_end().to_string());
}
if !current.is_empty() {
blocks.push(current);
}
blocks
}
fn parse_markdown_list_item(line: &str) -> Option<&str> {
let trimmed = strip_prompt_prefix(line.trim_start());
for prefix in ["- ", "* ", "+ "] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let rest = rest.trim();
return (!rest.is_empty()).then_some(rest);
}
}
let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
if digits > 0 {
let rest = &trimmed[digits..];
if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
let rest = rest.trim();
return (!rest.is_empty()).then_some(rest);
}
}
None
}
pub fn line_looks_like_markdown_list_item(line: &str) -> bool {
parse_markdown_list_item(line).is_some()
}
fn strip_prompt_prefix(line: &str) -> &str {
line.strip_prefix("❯ ")
.or_else(|| line.strip_prefix('❯'))
.map(str::trim_start)
.unwrap_or(line)
}
fn detect_orchestration_mode(text: &str) -> Option<OrchestrationRequestMode> {
let lower = text.to_ascii_lowercase();
if contains_any(
&lower,
&[
"dependency graph",
"depends on",
"blocked by",
"prerequisite",
"then unblock",
],
) || lower.contains("after #")
{
return Some(OrchestrationRequestMode::Dag);
}
if contains_any(
&lower,
&[
"fan out",
"concurrent",
"at the same time",
"in parallel",
"simultaneously",
"parallelize",
],
) {
return Some(OrchestrationRequestMode::Parallel);
}
if contains_any(
&lower,
&[
"orchestra",
"orcestra",
"orchestrate",
"chain",
"in order",
"one by one",
"sequential",
"sequentially",
"synchronous",
"run these sequentially",
],
) {
return Some(OrchestrationRequestMode::Sequential);
}
None
}
fn contains_any(text: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| text.contains(needle))
}
fn strip_pending_checkbox_prefix(line: &str) -> &str {
let trimmed = line.trim_start();
for prefix in ["[ ]", "[/]", "[x]", "[X]"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
return rest.trim_start();
}
}
if let Some(inner) = trimmed.strip_prefix("[/")
&& let Some(close) = inner.find(']')
{
let gate_type = &inner[..close];
if !gate_type.is_empty()
&& gate_type
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return inner[close + 1..].trim_start();
}
}
trimmed
}
fn starts_with_imperative_verb(line: &str) -> bool {
let mut words = line.split_whitespace();
let Some(first) = words.next() else {
return false;
};
if IMPERATIVE_LEADING_VERBS.contains(&first) {
return true;
}
matches!((first, words.next()), ("clean", Some("up")))
}
pub fn is_stale_snapshot(snapshot_content: &str, document_content: &str) -> bool {
let snap_stripped = strip_comments(snapshot_content);
let doc_stripped = strip_comments(document_content);
if doc_stripped.len() <= snap_stripped.len() {
return false;
}
let snap_trimmed = snap_stripped.trim_end();
let doc_trimmed = doc_stripped.trim_end();
if !doc_trimmed.starts_with(snap_trimmed) {
return false;
}
let extra = &doc_stripped[snap_trimmed.len()..];
let extra_trimmed = extra.trim();
if extra_trimmed.is_empty() {
return false;
}
if !extra_trimmed.contains("## Assistant") {
return false;
}
let parts: Vec<&str> = extra_trimmed.split("## User").collect();
if let Some(last_user_block) = parts.last() {
let user_content = last_user_block.trim();
user_content.is_empty()
} else {
false
}
}
const APPROVAL_WORDS: &[&str] = &[
"go", "yes", "do", "ok", "continue", "approve", "approved", "y", "yep", "yeah", "sure",
"proceed", "lgtm",
];
pub fn classify_diff(diff_text: &str) -> DiffClassification {
let mut added: Vec<String> = Vec::new();
let mut removed: Vec<String> = Vec::new();
let mut added_block_count = 0usize;
let mut in_added_block = false;
for line in diff_text.lines() {
if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
in_added_block = false;
continue;
}
if let Some(content) = line.strip_prefix('+') {
added.push(content.to_string());
if !in_added_block {
added_block_count += 1;
in_added_block = true;
}
} else if let Some(content) = line.strip_prefix('-') {
removed.push(content.to_string());
in_added_block = false;
} else {
in_added_block = false;
}
}
let added_content: Vec<&str> = added
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let removed_content: Vec<&str> = removed
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if is_boundary_artifact(&added_content, &removed_content) {
return DiffClassification {
diff_type: DiffType::BoundaryArtifact,
diff_type_reason: "only boundary marker or (HEAD) changes".into(),
};
}
if added_content.len() == 1 && removed_content.is_empty() {
let word = added_content[0].to_lowercase();
let word_clean = word.trim_end_matches(|c: char| c.is_ascii_punctuation());
if APPROVAL_WORDS.contains(&word_clean) {
return DiffClassification {
diff_type: DiffType::Approval,
diff_type_reason: format!("single approval word: \"{}\"", added_content[0]),
};
}
}
if added_content.len() == 1 && removed_content.is_empty() && added_content[0].ends_with('?') {
return DiffClassification {
diff_type: DiffType::SimpleQuestion,
diff_type_reason: format!(
"single question: \"{}\"",
truncate_for_reason(added_content[0])
),
};
}
if !added_content.is_empty()
&& !removed_content.is_empty()
&& is_annotation(&added_content, &removed_content)
{
return DiffClassification {
diff_type: DiffType::Annotation,
diff_type_reason: "inline edit to existing content".into(),
};
}
if added_content.is_empty() && !removed_content.is_empty() {
return DiffClassification {
diff_type: DiffType::StructuralChange,
diff_type_reason: format!("{} lines removed, no additions", removed_content.len()),
};
}
let has_separator = added.iter().any(|l| l.trim() == "---");
if has_separator && added_content.len() >= 2 {
let section_count = added_content
.split(|l| *l == "---")
.filter(|s| !s.is_empty())
.count();
if section_count >= 2 {
return DiffClassification {
diff_type: DiffType::MultiTopic,
diff_type_reason: format!("{} topics separated by ---", section_count),
};
}
}
if added_block_count >= 2 && added_content.len() >= 2 {
return DiffClassification {
diff_type: DiffType::MultiTopic,
diff_type_reason: format!("{} distinct added blocks", added_block_count),
};
}
DiffClassification {
diff_type: DiffType::ContentAddition,
diff_type_reason: format!(
"{} lines added{}",
added_content.len(),
if !removed_content.is_empty() {
format!(", {} removed", removed_content.len())
} else {
String::new()
}
),
}
}
fn is_boundary_artifact(added: &[&str], removed: &[&str]) -> bool {
let is_boundary_line =
|line: &str| -> bool { is_boundary_artifact_line(line) || line.is_empty() };
if added.is_empty() && removed.is_empty() {
return false;
}
if added.iter().all(|l| is_boundary_line(l)) && removed.iter().all(|l| is_boundary_line(l)) {
return true;
}
if added.len() == removed.len() {
return added.iter().zip(removed.iter()).all(|(a, r)| {
let a_trim = a.trim();
let r_trim = r.trim();
if a_trim == r_trim {
return true;
}
if a_trim.starts_with("<!-- agent:boundary:")
&& r_trim.starts_with("<!-- agent:boundary:")
{
return true;
}
if is_head_boundary_artifact(a_trim)
&& a_trim
.strip_suffix(" (HEAD)")
.is_some_and(|base| base.trim() == r_trim)
{
return true;
}
if is_head_boundary_artifact(r_trim)
&& r_trim
.strip_suffix(" (HEAD)")
.is_some_and(|base| base.trim() == a_trim)
{
return true;
}
false
});
}
false
}
fn is_annotation(added: &[&str], removed: &[&str]) -> bool {
if added.len() != removed.len() {
return false;
}
added.iter().zip(removed.iter()).all(|(a, r)| {
a.starts_with(r)
|| {
let min_len = a.len().min(r.len());
if min_len < 3 {
return false;
}
let common = a
.chars()
.zip(r.chars())
.take_while(|(a, b)| a == b)
.count();
common > min_len * 6 / 10
}
})
}
fn truncate_for_reason(s: &str) -> &str {
if s.len() <= 80 { s } else { &s[..80] }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diff_format_additions() {
use similar::{ChangeTag, TextDiff};
let previous = "line1\n";
let current = "line1\nline2\n";
let diff = TextDiff::from_lines(previous, current);
let has_insert = diff
.iter_all_changes()
.any(|c| c.tag() == ChangeTag::Insert);
assert!(has_insert);
}
#[test]
fn diff_format_deletions() {
use similar::{ChangeTag, TextDiff};
let previous = "line1\nline2\n";
let current = "line1\n";
let diff = TextDiff::from_lines(previous, current);
let has_delete = diff
.iter_all_changes()
.any(|c| c.tag() == ChangeTag::Delete);
assert!(has_delete);
}
#[test]
fn diff_format_unchanged() {
use similar::{ChangeTag, TextDiff};
let content = "line1\nline2\n";
let diff = TextDiff::from_lines(content, content);
let all_equal = diff.iter_all_changes().all(|c| c.tag() == ChangeTag::Equal);
assert!(all_equal);
}
#[test]
fn diff_format_mixed() {
use similar::{ChangeTag, TextDiff};
let previous = "line1\nline2\nline3\n";
let current = "line1\nchanged\nline3\n";
let diff = TextDiff::from_lines(previous, current);
let mut output = String::new();
for change in diff.iter_all_changes() {
let prefix = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
output.push_str(prefix);
output.push_str(change.value());
}
assert!(output.contains(" line1\n"));
assert!(output.contains("-line2\n"));
assert!(output.contains("+changed\n"));
assert!(output.contains(" line3\n"));
}
#[test]
fn strip_html_comment() {
let input = "before\n<!-- a comment -->\nafter\n";
assert_eq!(strip_comments(input), "before\nafter\n");
}
#[test]
fn strip_multiline_html_comment() {
let input = "before\n<!--\nmulti\nline\n-->\nafter\n";
assert_eq!(strip_comments(input), "before\nafter\n");
}
#[test]
fn strip_link_ref_comment() {
let input = "before\n[//]: # (a comment)\nafter\n";
assert_eq!(strip_comments(input), "before\nafter\n");
}
#[test]
fn preserve_agent_markers() {
let input = "<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
assert_eq!(strip_comments(input), input);
}
#[test]
fn strip_regular_keep_agent_marker() {
let input = "<!-- regular comment -->\n<!-- agent:s -->\ndata\n<!-- /agent:s -->\n";
assert_eq!(
strip_comments(input),
"<!-- agent:s -->\ndata\n<!-- /agent:s -->\n"
);
}
#[test]
fn strip_inline_comment() {
let input = "text <!-- note --> more\n";
let result = strip_comments(input);
assert_eq!(result, "text more\n");
}
#[test]
fn no_comments_unchanged() {
let input = "# Title\n\nJust text.\n";
assert_eq!(strip_comments(input), input);
}
#[test]
fn empty_document() {
assert_eq!(strip_comments(""), "");
}
#[test]
fn stale_snapshot_detects_completed_exchange() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
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";
assert!(is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_false_when_user_has_new_content() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
let document =
"## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\nNew question here\n";
assert!(!is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_false_when_identical() {
let content = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
assert!(!is_stale_snapshot(content, content));
}
#[test]
fn stale_snapshot_false_when_no_assistant_block() {
let snapshot = "## User\n\nHello\n\n";
let document = "## User\n\nHello\n\nSome random text\n\n## User\n\n";
assert!(!is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_multiple_exchanges_stale() {
let snapshot = "## User\n\nQ1\n\n## Assistant\n\nA1\n\n## User\n\n";
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";
assert!(is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_with_inline_annotation_not_stale() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi there\n\n## User\n\n";
let document =
"## User\n\nHello\n\n## Assistant\n\nHi there\n\nPlease elaborate\n\n## User\n\n";
assert!(!is_stale_snapshot(snapshot, document));
}
#[test]
fn stale_snapshot_ignores_comments_in_detection() {
let snapshot = "## User\n\nHello\n\n## Assistant\n\nHi\n\n## User\n\n";
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";
assert!(is_stale_snapshot(snapshot, document));
}
#[test]
fn copy_on_read_guard_skips_recovery_when_snapshot_modified() {
use std::time::SystemTime;
let t1 = Some(SystemTime::UNIX_EPOCH);
let t2 = Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1));
assert_eq!(t1, t1, "same mtime should allow recovery");
assert_ne!(t1, t2, "different mtime should block recovery");
let none: Option<SystemTime> = None;
assert_eq!(none, none, "both None should allow recovery");
}
#[test]
fn strip_preserves_comment_syntax_in_inline_backticks() {
let input =
"Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n";
let result = strip_comments(input);
assert_eq!(
result,
"Use `<!--` to start a comment.\n<!-- agent:foo -->\ncontent\n<!-- /agent:foo -->\n"
);
}
#[test]
fn strip_preserves_comment_syntax_in_fenced_code_block() {
let input = "before\n```\n<!-- not a comment -->\n```\nafter\n";
let result = strip_comments(input);
assert_eq!(result, input);
}
#[test]
fn strip_backtick_comment_before_agent_marker() {
let input = "\
Text mentions `<!--` as a trigger.\n\
More text here.\n\
New user content.\n\
<!-- /agent:exchange -->\n";
let result = strip_comments(input);
assert_eq!(result, input);
}
#[test]
fn strip_multiple_backtick_comments_in_exchange() {
let snapshot = "\
<!-- agent:exchange -->\n\
Discussion about `<!--` triggers.\n\
- `<!-- agent:NAME -->` paired markers\n\
<!-- /agent:exchange -->\n";
let current = "\
<!-- agent:exchange -->\n\
Discussion about `<!--` triggers.\n\
- `<!-- agent:NAME -->` paired markers\n\
\n\
Please fix the bug.\n\
<!-- /agent:exchange -->\n";
let snap_stripped = strip_comments(snapshot);
let curr_stripped = strip_comments(current);
assert_ne!(
snap_stripped, curr_stripped,
"inline edits after backtick-comment text must be detected"
);
}
fn make_diff(added: &[&str], removed: &[&str]) -> String {
let mut lines = vec!["--- snapshot", "+++ document", "@@ -1,5 +1,5 @@"];
for r in removed {
lines.push(r);
}
lines.push(" context line");
for a in added {
lines.push(a);
}
lines.join("\n")
}
#[test]
fn classify_approval() {
let diff = make_diff(&["+go"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::Approval);
assert!(c.diff_type_reason.contains("go"));
}
#[test]
fn classify_approval_case_insensitive() {
let diff = make_diff(&["+Yes"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::Approval);
}
#[test]
fn classify_simple_question() {
let diff = make_diff(&["+what is the release process?"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::SimpleQuestion);
}
#[test]
fn classify_boundary_artifact() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-### Re: Something (HEAD)\n+### Re: Something\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
}
#[test]
fn classify_boundary_uuid() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,3 @@\n-<!-- agent:boundary:abc123 -->\n+<!-- agent:boundary:def456 -->\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::BoundaryArtifact);
}
#[test]
fn classify_head_mention_in_user_prose_as_content_addition() {
let diff = make_diff(
&[
"+`❯ ` prompt prefix is being stripped away by the uncommitted user affordance that adds the ` (HEAD)` suffix. spec-test-build-install-commit-push",
],
&[],
);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::ContentAddition);
}
#[test]
fn classify_structural_change() {
let diff = "--- snapshot\n+++ document\n@@ -1,5 +1,3 @@\n context\n-removed line one\n-removed line two\n context\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::StructuralChange);
}
#[test]
fn classify_multi_topic() {
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";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::MultiTopic);
}
#[test]
fn classify_multi_topic_with_separator() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n context\n+question one?\n+---\n+do something else\n context end\n";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::MultiTopic);
}
#[test]
fn classify_content_addition() {
let diff = make_diff(&["+implement the feature using Rust"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::ContentAddition);
}
#[test]
fn classify_annotation() {
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";
let c = classify_diff(diff);
assert_eq!(c.diff_type, DiffType::Annotation);
}
#[test]
fn classify_approval_in_sentence_is_content() {
let diff = make_diff(&["+let's go ahead and implement the feature"], &[]);
let c = classify_diff(&diff);
assert_eq!(c.diff_type, DiffType::ContentAddition);
}
#[test]
fn classify_single_separator_not_multi_topic() {
let diff = make_diff(&["+---"], &[]);
let c = classify_diff(&diff);
assert_ne!(c.diff_type, DiffType::MultiTopic);
}
#[test]
fn classify_question_mark_in_multiline_is_content() {
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";
let c = classify_diff(diff);
assert_ne!(c.diff_type, DiffType::SimpleQuestion);
}
#[test]
fn annotate_diff_additions() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n context line\n+new user line\n more context\n";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[user+] new user line"));
assert!(annotated.contains("[agent] context line"));
}
#[test]
fn annotate_diff_removals() {
let diff =
"--- snapshot\n+++ document\n@@ -1,3 +1,2 @@\n context\n-removed line\n context\n";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[user-] removed line"));
}
#[test]
fn annotate_diff_modifications() {
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";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[user~] The fix is deployed: confirmed in prod"));
assert!(!annotated.contains("[user-] The fix is deployed"));
}
#[test]
fn annotate_diff_context() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n line one\n line two\n+added\n line three\n";
let annotated = annotate_diff(diff).unwrap();
assert!(annotated.contains("[agent] line one"));
assert!(annotated.contains("[agent] line two"));
assert!(annotated.contains("[agent] line three"));
}
#[test]
fn annotate_diff_empty() {
let diff = "--- snapshot\n+++ document\n";
assert!(annotate_diff(diff).is_none());
}
#[test]
fn inline_annotations_user_addition_between_agent_lines() {
let annotated = "[agent] previous agent line\n\
[user+] This is wrong, fix it\n\
[agent] more agent content";
let anns = extract_inline_annotations(annotated);
assert_eq!(anns, vec!["This is wrong, fix it"]);
}
#[test]
fn inline_annotations_user_modification_between_agent_lines() {
let annotated = "[agent] context before\n\
[user~] The corrected line\n\
[agent] context after";
let anns = extract_inline_annotations(annotated);
assert_eq!(anns, vec!["The corrected line"]);
}
#[test]
fn inline_annotations_user_addition_at_end_is_not_inline() {
let annotated = "[agent] agent content\n\
[agent] more agent content\n\
[user+] New user input at end";
let anns = extract_inline_annotations(annotated);
assert!(anns.is_empty());
}
#[test]
fn inline_annotations_component_markers_not_substantive() {
let annotated = "[agent] response prose\n\
[user+] The fix did not seem to work\n\
[agent] <!-- /agent:exchange -->\n\
[agent] \n\
[agent] ## Pending / Not Built\n\
[agent] \n\
[agent] <!-- agent:pending -->";
let anns = extract_inline_annotations(annotated);
assert!(
anns.is_empty(),
"component markers should not make end-of-exchange input inline"
);
}
#[test]
fn inline_annotations_head_boundary_artifact_excluded() {
let annotated = "[agent] previous content\n\
[user~] ### Re: topic — sonnet-4-6 (HEAD)\n\
[agent] response prose\n\
[agent] more content";
let anns = extract_inline_annotations(annotated);
assert!(
anns.is_empty(),
"(HEAD) boundary reposition should not be an inline annotation"
);
}
#[test]
fn inline_annotations_real_correction_between_prose() {
let annotated = "[agent] The score is 5 out of 10.\n\
[user+] This is wrong. Score should be 8-9.\n\
[agent] Here is more analysis below.";
let anns = extract_inline_annotations(annotated);
assert_eq!(anns, vec!["This is wrong. Score should be 8-9."]);
}
#[test]
fn inline_annotations_multiple() {
let annotated = "[agent] ### Re: topic\n\
[user+] First correction\n\
[agent] agent paragraph\n\
[user+] Second correction\n\
[agent] agent closing\n\
[user+] New input at end";
let anns = extract_inline_annotations(annotated);
assert_eq!(anns, vec!["First correction", "Second correction"]);
}
#[test]
fn inline_annotations_skips_blank_user_lines() {
let annotated = "[agent] agent line\n\
[user+] \n\
[agent] more agent";
let anns = extract_inline_annotations(annotated);
assert!(anns.is_empty());
}
#[test]
fn inline_annotations_empty_annotated_diff() {
let anns = extract_inline_annotations("");
assert!(anns.is_empty());
}
#[test]
fn inline_annotations_claudescore_table_scenario() {
let annotated = "[agent] | Category | Score |\n\
[agent] |----------|-------|\n\
[agent] | Quality | 7 |\n\
[user+] This is wrong. Do not lower expert scores.\n\
[agent] | Speed | 8 |\n\
[user+] We may need to broaden the gate.\n\
[agent] | Total | 7.5 |";
let anns = extract_inline_annotations(annotated);
assert_eq!(
anns,
vec![
"This is wrong. Do not lower expert scores.",
"We may need to broaden the gate.",
]
);
}
#[test]
fn classify_prompt_bearing_changes_promotes_inline_prompt_to_prompt_target() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
The prior explanation was incomplete\n\
+Why was the `❯` prefix omitted here?\n\
The rest of the response stays the same\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
assert_eq!(changes[0].text, "Why was the `❯` prefix omitted here?");
}
#[test]
fn classify_prompt_bearing_changes_promotes_plain_exchange_tail_to_prompt_target() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
Done.\n\
+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\
<!-- /agent:exchange -->\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
assert_eq!(
changes[0].text,
"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"
);
}
#[test]
fn classify_prompt_bearing_changes_promotes_bare_slash_command_to_prompt_target() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
### Re: older — gpt-5\n\
+/clear\n\
<!-- /agent:exchange -->\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, PromptBearingChangeKind::PromptTarget);
assert_eq!(changes[0].text, "/clear");
assert!(line_looks_like_fresh_prompt_after_response("/clear"));
assert!(!text_line_looks_like_prompt_target(
"/home/brian/work/foo.md"
));
}
#[test]
fn prefixed_markdown_response_labels_are_not_prompt_targets() {
for line in [
"❯ **Verification:** Both redirects confirmed via `curl`.",
"❯ Commit / push:",
"❯ **Commit / push:**",
"❯ - **Verification:** `cargo test` passed.",
"❯ 1. **What changed:** normalized response labels.",
] {
assert!(
!text_line_looks_like_prompt_target(line),
"assistant response label must not be classified as a prompt target: {line}"
);
assert!(
line_looks_like_plain_response_after_prompt(line),
"assistant response label must remain response prose: {line}"
);
}
}
#[test]
fn prefixed_user_followup_after_response_still_starts_prompt() {
for line in [
"❯ verify deploy status",
"❯ Verification failed; what next?",
"❯ do [#respfx]. spec-test-build-install-commit-push",
] {
assert!(
text_line_looks_like_prompt_target(line),
"real user follow-up must stay prompt-bearing: {line}"
);
assert!(
line_looks_like_fresh_prompt_after_response(line),
"real user follow-up must start a new prompt run: {line}"
);
}
}
#[test]
fn classify_prompt_bearing_changes_ignores_prefixed_markdown_response_label() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
### Re: done — gpt-5\n\
+❯ **Verification:** Both redirects confirmed via `curl`.\n\
<!-- /agent:exchange -->\n";
let changes = classify_prompt_bearing_changes(diff);
assert!(
changes.is_empty(),
"prefixed assistant response label must not reopen a cycle: {changes:?}"
);
}
#[test]
fn classify_prompt_bearing_changes_ignores_prefixed_recovery_evidence() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,11 @@\n\
ctx\n\
+### Re: stalled Run Agent Doc recovery — gpt-5\n\
+\n\
+Recovered the stranded closeout first.\n\
+\n\
+❯ - `./gradlew test --tests 'TerminalUtilTest.protected prompt input route refusal is actionable not persistent failure'`\n\
+❯ - `cargo test -p agent-doc-orchestration protected_prompt_draft_preview_redacts_and_bounds_latest_draft`\n\
+❯ - `612b1552` `test: cover protected prompt route refusal`\n\
+\n\
+No code files changed in this follow-up.\n\
<!-- /agent:exchange -->\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(
changes.len(),
1,
"recovery evidence must stay grouped: {changes:?}"
);
assert_eq!(changes[0].kind, PromptBearingChangeKind::RecoveryArtifact);
assert!(
!changes
.iter()
.any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
"prompt-prefixed recovery evidence must not become prompt targets: {changes:?}"
);
}
#[test]
fn classify_prompt_bearing_changes_marks_inline_correction_as_content_edit() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,4 @@\n\
The service returned 401 from this endpoint\n\
+The service returned 503 from this endpoint\n\
The rest of the response stays the same\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
assert_eq!(
changes[0].text,
"The service returned 503 from this endpoint"
);
}
#[test]
fn classify_prompt_bearing_changes_marks_response_heading_as_recovery_artifact() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
ctx\n\
+### Re: missed patchback — gpt-5\n\
+Patched after the fact.\n\
context end\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, PromptBearingChangeKind::RecoveryArtifact);
assert_eq!(
changes[0].text,
"### Re: missed patchback — gpt-5\nPatched after the fact."
);
}
#[test]
fn classify_prompt_bearing_changes_marks_ipc_proof_diagnostic_block_as_recovery_artifact() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,12 @@\n\
ctx\n\
+### Re: IPC proof diagnostic (interrupted-cycle recovery) — agent-doc\n\
+\n\
+```text\n\
+**IPC proof issue dogfood log**\n\
+This is binary-authored diagnostic content, not a user prompt, so it does not require a separate response cycle.\n\
+Issue class: `ipc_proof_insufficient`\n\
+ipc_proof_insufficient file=/tmp/session.md source=socket_ack_content patch_id=abc invariant=live_prompt_drift_after_preflight recovery=content_ours_snapshot_next_cycle\n\
+```\n\
<!-- /agent:exchange -->\n";
let changes = classify_prompt_bearing_changes(diff);
assert!(
!changes
.iter()
.any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
"intact IPC-proof diagnostic must not classify as a PromptTarget: {changes:?}"
);
}
#[test]
fn classify_prompt_bearing_changes_marks_separated_ipc_proof_line_as_recovery_artifact() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
ctx\n\
+ipc_proof_insufficient file=/tmp/session.md source=socket_ack_content patch_id=abc invariant=live_prompt_drift_after_preflight recovery=content_ours_snapshot_next_cycle\n\
<!-- /agent:exchange -->\n";
let changes = classify_prompt_bearing_changes(diff);
assert!(
!changes
.iter()
.any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
"a separated binary-authored ipc_proof_insufficient line must not be a PromptTarget: {changes:?}"
);
assert!(
changes
.iter()
.any(|change| change.kind == PromptBearingChangeKind::RecoveryArtifact),
"the separated diagnostic line must classify as a RecoveryArtifact: {changes:?}"
);
}
#[test]
fn classify_prompt_bearing_changes_keeps_real_prompt_mentioning_ipc_as_prompt_target() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,5 @@\n\
ctx\n\
+The IPC drift keeps breaking my finalize — please diagnose the root cause and fix it.\n\
<!-- /agent:exchange -->\n";
let changes = classify_prompt_bearing_changes(diff);
assert!(
changes
.iter()
.any(|change| change.kind == PromptBearingChangeKind::PromptTarget),
"a real user prompt that mentions ipc/drift must remain a PromptTarget: {changes:?}"
);
}
#[test]
fn classify_prompt_bearing_changes_marks_boundary_only_edit_as_boundary_artifact() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n\
-### Re: Something\n\
+### Re: Something (HEAD)\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, PromptBearingChangeKind::BoundaryArtifact);
assert_eq!(changes[0].text, "### Re: Something (HEAD)");
}
#[test]
fn classify_prompt_bearing_changes_preserves_mixed_triage_order() {
let diff = "--- snapshot\n+++ document\n@@ -1,7 +1,12 @@\n\
The service returned 401 from this endpoint\n\
+The service returned 503 from this endpoint\n\
The rest of the response stays the same\n\
+### Re: delayed patchback — gpt-5\n\
+Patched after the fact.\n\
Context after repair\n\
+<!-- agent:boundary:test-boundary -->\n\
More context\n\
+Why was the `❯` prefix omitted here?\n\
The response continues below\n";
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 4);
assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
assert_eq!(
changes[0].text,
"The service returned 503 from this endpoint"
);
assert_eq!(changes[1].kind, PromptBearingChangeKind::RecoveryArtifact);
assert_eq!(
changes[1].text,
"### Re: delayed patchback — gpt-5\nPatched after the fact."
);
assert_eq!(changes[2].kind, PromptBearingChangeKind::BoundaryArtifact);
assert_eq!(changes[2].text, "<!-- agent:boundary:test-boundary -->");
assert_eq!(changes[3].kind, PromptBearingChangeKind::PromptTarget);
assert_eq!(changes[3].text, "Why was the `❯` prefix omitted here?");
}
#[test]
fn classify_prompt_bearing_changes_ignores_raw_answered_stale_exchange_tail() {
let diff = concat!(
"--- snapshot\n",
"+++ document\n",
"@@ -19,11 +19,11 @@\n",
" ---\n",
" \n",
" ## Status\n",
" \n",
" <!-- agent:status patch=replace -->\n",
"-Blocked on environment: GitHub is unreachable from this sandbox, so I cannot rename `ClaudeScore/BuildPartyInvestorDemo` or add the follow-up submodule here.\n",
"+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",
" <!-- /agent:status -->\n",
" \n",
" ## Exchange\n",
" \n",
" <!-- agent:exchange patch=append -->\n",
"@@ -60,10 +60,17 @@\n",
" ```bash\n",
" git submodule add git@github.com:ClaudeScore/buildparty-investor-demo.git buildparty-investor-demo\n",
" ```\n",
" \n",
" 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",
"+I renamed the repo to ClaudeScore/buildparty-investor-demo. Please update references\n",
"+I updated the repo-local references to the renamed GitHub repo.\n",
"+\n",
"+- `.gitmodules` now uses `git@github.com:ClaudeScore/buildparty-investor-demo.git`\n",
"+- The checked-out submodule at `buildparty-investor-demo/` now has `origin` set to the same URL\n",
"+\n",
"+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",
" <!-- /agent:exchange -->\n",
" \n",
" ## Queue\n",
" \n",
" <!-- agent:queue -->\n",
);
let changes = classify_prompt_bearing_changes(diff);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, PromptBearingChangeKind::ContentEdit);
assert_eq!(
changes[0].text,
"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."
);
}
#[test]
fn classify_prompt_bearing_changes_ignores_answered_prompt_before_blank_heading_gap() {
let diff = concat!(
"--- snapshot\n",
"+++ document\n",
"@@ -1,3 +1,8 @@\n",
" <!-- agent:exchange patch=append -->\n",
"-<!-- agent:boundary:initial -->\n",
"+❯ do #sim1. spec-test-build-install-commit-push\n",
"+\n",
"+### Re: sim closeout — gpt-5 (HEAD)\n",
"+\n",
"+Done.\n",
"+<!-- agent:boundary:new -->\n",
" <!-- /agent:exchange -->\n",
);
let changes = classify_prompt_bearing_changes(diff);
assert!(
changes
.iter()
.all(|change| change.kind != PromptBearingChangeKind::PromptTarget),
"answered prompt should not remain unresolved: {changes:?}"
);
}
#[test]
fn parse_slash_commands_simple() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/clear"]);
}
#[test]
fn parse_slash_command_only_added_diff_accepts_bare_clear() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n context\n+/clear\n";
assert_eq!(
parse_slash_command_only_added_diff(diff),
Some(vec!["/clear".to_string()])
);
}
#[test]
fn parse_slash_command_only_added_diff_rejects_mixed_prompt_text() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n context\n+/clear\n+Why was this answered?\n";
assert_eq!(parse_slash_command_only_added_diff(diff), None);
}
#[test]
fn parse_slash_command_only_added_diff_rejects_fenced_or_blockquoted_commands() {
let fenced = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
assert_eq!(parse_slash_command_only_added_diff(fenced), None);
let quoted = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
assert_eq!(parse_slash_command_only_added_diff(quoted), None);
}
#[test]
fn parse_slash_commands_trims_surrounding_whitespace() {
let diff = "--- snapshot\n+++ queue\n@@ -0,0 +1,1 @@\n+ /clear \n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/clear"]);
}
#[test]
fn parse_slash_commands_with_args() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+/agent-doc foo.md\n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/agent-doc foo.md"]);
}
#[test]
fn parse_slash_commands_ignores_fenced() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+/clear\n+```\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_ignores_blockquote() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> /clear\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_ignores_context_lines() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,2 @@\n /clear\n context\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_ignores_removed_lines() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,1 @@\n-/clear\n context\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_requires_letter_after_slash() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/ foo\n+//comment\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty());
}
#[test]
fn parse_slash_commands_multiple() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n+/clear\n+/agent-doc foo.md\n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/clear", "/agent-doc foo.md"]);
}
#[test]
fn parse_slash_commands_rejects_absolute_paths() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
+/home/brian/work/foo.md\n\
+/tmp/scratch\n\
+/usr/local/bin/agent-doc\n\
+/var\n";
let cmds = parse_slash_commands(diff);
assert_eq!(cmds, vec!["/var"]);
}
#[test]
fn parse_slash_commands_rejects_uppercase_and_symbols() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
+/Clear\n\
+/foo.bar\n\
+/foo!bang\n\
+/foo#hash\n";
let cmds = parse_slash_commands(diff);
assert!(cmds.is_empty(), "all four must be rejected; got: {cmds:?}");
}
#[test]
fn parse_slash_commands_accepts_namespaced_and_hyphenated() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,5 @@\n ctx\n\
+/mcp:reload\n\
+/agent-doc file.md\n\
+/some_thing\n\
+/v2\n";
let cmds = parse_slash_commands(diff);
assert_eq!(
cmds,
vec!["/mcp:reload", "/agent-doc file.md", "/some_thing", "/v2"]
);
}
#[test]
fn detect_orchestration_request_for_synchronous_task_list() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,7 @@\n ctx\n\
+Today is 2026-04-25. Synchronous orcestra.\n\
+- do #ss01. Add tests. Run benchmarks.\n\
+- do #ss02. Add tests. Run benchmarks.\n\
+- do #ss03. Add tests. Run benchmarks.\n";
let request = detect_orchestration_request(diff).expect("expected orchestration request");
assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
assert_eq!(request.task_count, 3);
assert_eq!(
request.trigger_text,
"Today is 2026-04-25. Synchronous orcestra."
);
}
#[test]
fn detect_orchestration_request_for_parallel_batch() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n\
+Fan out these benchmark tasks.\n\
+- do #a1\n\
+- do #a2\n";
let request = detect_orchestration_request(diff).expect("expected orchestration request");
assert_eq!(request.mode, OrchestrationRequestMode::Parallel);
assert_eq!(request.task_count, 2);
}
#[test]
fn detect_orchestration_request_requires_batch_shape() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
+Run these in order.\n\
+- do #a1\n";
assert!(
detect_orchestration_request(diff).is_none(),
"single-item lists should stay as ordinary work, not forced orchestration"
);
}
#[test]
fn detect_orchestration_request_for_prefixed_synchronous_opera_batch() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
+❯ synchronous opera\n\
+❯ preset #spec-test-build-install-commit-push\n\
+❯ - do #jbpfx1\n\
+❯ - do #jbpfx2\n";
let request = detect_orchestration_request(diff).expect("expected orchestration request");
assert_eq!(request.mode, OrchestrationRequestMode::Sequential);
assert_eq!(request.task_count, 2);
assert_eq!(
request.trigger_text,
"❯ synchronous opera ❯ preset #spec-test-build-install-commit-push"
);
}
#[test]
fn detect_orchestration_request_ignores_prefixed_recovery_evidence() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,10 @@\n ctx\n\
+### Re: stalled Run Agent Doc recovery — gpt-5\n\
+\n\
+I dry-ran the emitted orchestration command. It resolved only already-recorded verification evidence.\n\
+\n\
+❯ - `./gradlew test --tests TerminalUtilTest`\n\
+❯ - `cargo test -p agent-doc-orchestration protected_prompt_draft_preview_redacts_and_bounds_latest_draft`\n\
+❯ - `612b1552` `test: cover protected prompt route refusal`\n\
+\n\
+No code files changed in this follow-up.\n";
assert!(
detect_orchestration_request(diff).is_none(),
"recovered response evidence must not emit orchestration work"
);
}
#[test]
fn detect_queue_trigger_do_queue() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue\n";
assert!(detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_run_queue() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+run queue\n";
assert!(detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_case_insensitive() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+Do Queue\n";
assert!(detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_with_prompt_prefix() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+❯ do queue\n";
assert!(detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_not_pending_ref() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do #queue Phase 2\n";
assert!(!detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_not_in_code_fence() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,4 @@\n ctx\n+```\n+do queue\n+```\n";
assert!(!detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_not_in_blockquote() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+> do queue\n";
assert!(!detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_with_trailing_punct() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+do queue.\n";
assert!(detect_queue_trigger(diff));
}
#[test]
fn detect_queue_trigger_not_on_context_line() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n do queue\n+other\n";
assert!(!detect_queue_trigger(diff));
}
#[test]
fn suppress_inactive_queue_additions_removes_queue_prompt_lines() {
let current = concat!(
"---\nqueue_active: false\n---\n\n",
"<!-- agent:exchange -->\n",
"### Re: prior — gpt-5\n",
"Done.\n",
"<!-- /agent:exchange -->\n\n",
"<!-- agent:queue -->\n",
"dispatch #spec-test-build-install-commit-push\n",
"- do [#gdbpropscan]\n",
"<!-- /agent:queue -->\n"
);
let diff = concat!(
"--- snapshot\n",
"+++ document\n",
"@@ -7,5 +7,7 @@\n",
" Done.\n",
" <!-- /agent:exchange -->\n",
" \n",
" <!-- agent:queue -->\n",
"+dispatch #spec-test-build-install-commit-push\n",
"+- do [#gdbpropscan]\n",
" <!-- /agent:queue -->\n",
);
let filtered = suppress_inactive_queue_additions(diff, current);
assert!(!filtered.contains("[#gdbpropscan]"));
assert!(!filtered.contains("dispatch #spec-test-build-install-commit-push"));
assert!(classify_prompt_bearing_changes(&filtered).is_empty());
assert!(extract_imperative_directives(&filtered).is_empty());
}
#[test]
fn detect_prompt_preset_requests_from_diff() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
+preset #1\n\
+presets release-check, #2\n\
+preset #1\n\
+> preset ignored\n\
+```md\n\
+preset fenced\n";
assert_eq!(
detect_prompt_preset_requests(diff),
vec![
"#1".to_string(),
"release-check".to_string(),
"#2".to_string()
]
);
}
#[test]
fn extract_prompt_preset_requests_from_text_ignores_fences_and_blockquotes() {
let text = "synchronous orchestra\npreset #1\n> preset quoted\n\n```md\npreset fenced\n```\npresets release-check and #2\n";
assert_eq!(
extract_prompt_preset_requests_from_text(text),
vec![
"#1".to_string(),
"release-check".to_string(),
"#2".to_string()
]
);
}
#[test]
fn extract_imperative_directives_detects_do_and_build_push() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,3 @@\n ctx\n\
+do #6zyp. update spec + tests. build + install for local testing. commit + push\n\
+do [#dodone]. spec-test-build-install-commit-push\n\
+do [#plainid]\n\
+run benchmarks\n";
let directives = extract_imperative_directives(diff);
assert_eq!(
directives,
vec![
"do #6zyp. update spec + tests. build + install for local testing. commit + push",
"do [#dodone]. spec-test-build-install-commit-push",
"do [#plainid]",
"run benchmarks",
]
);
assert!(diff_contains_imperative_directive(diff));
}
#[test]
fn extract_imperative_directives_detects_pending_item_natural_language() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
+- [ ] [#n8q4] Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE\n";
let directives = extract_imperative_directives(diff);
assert_eq!(
directives,
vec!["Fix the cross-repo `no-permissions-bypass` miss now dominating benchmark MAE"]
);
assert!(diff_contains_imperative_directive(diff));
}
#[test]
fn extract_imperative_directives_detects_long_custom_pending_id() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n\
+- [ ] [#sdig2matrix] Fix the custom backlog id normalization path\n";
let directives = extract_imperative_directives(diff);
assert_eq!(
directives,
vec!["Fix the custom backlog id normalization path"]
);
assert!(diff_contains_imperative_directive(diff));
}
#[test]
fn extract_imperative_directives_ignores_blockquotes_and_fences() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,6 @@\n ctx\n\
+> do #skip\n\
+```\n\
+run tests\n\
+```\n\
+plain note\n";
let directives = extract_imperative_directives(diff);
assert!(
directives.is_empty(),
"unexpected directives: {directives:?}"
);
assert!(!diff_contains_imperative_directive(diff));
}
#[test]
fn diff_contains_imperative_directive_for_approval_word() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+go\n";
assert!(diff_contains_imperative_directive(diff));
}
#[test]
fn detect_exchange_compaction_request_matches_bare_directive() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+compact exchange\n";
assert!(detect_exchange_compaction_request(diff));
}
#[test]
fn detect_exchange_compaction_request_matches_prompt_prefixed_variant() {
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";
assert!(detect_exchange_compaction_request(diff));
}
#[test]
fn detect_exchange_compaction_request_ignores_non_directive_mentions() {
let diff = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+I failed to compact exchange earlier.\n";
assert!(!detect_exchange_compaction_request(diff));
}
#[test]
fn detect_exchange_compaction_request_ignores_queue_list_item() {
for diff in [
"--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+- Compact exchange should commit the compacted content\n",
"--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+* compact exchange should commit the compacted content\n",
"--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+1. compact exchange should commit the compacted content\n",
] {
assert!(
!detect_exchange_compaction_request(diff),
"list-item compaction mention must not be a directive: {diff:?}"
);
}
let prose = "--- snapshot\n+++ document\n@@ -1 +1,2 @@\n ctx\n+compact exchange\n";
assert!(detect_exchange_compaction_request(prose));
}
#[test]
fn extract_required_response_blocks_multiple_prompts() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,9 @@\n\
ctx\n\
+❯ First question?\n\
+Context line.\n\
+\n\
+❯ Second question?\n\
+do #n8q4. run tests. build + install. commit + push\n";
let blocks = extract_required_response_blocks(diff);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0], "❯ First question?\nContext line.");
assert_eq!(
blocks[1],
"❯ Second question?\ndo #n8q4. run tests. build + install. commit + push"
);
}
#[test]
fn extract_required_response_blocks_preserves_code_fence_context() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
ctx\n\
+❯ In src/boost-client, why did patchback miss the prefix?\n\
+See my inquiry:\n\
+```text\n\
+line one\n\
+line two\n\
+```\n";
let blocks = extract_required_response_blocks(diff);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].contains("❯ In src/boost-client"));
assert!(blocks[0].contains("```text\nline one\nline two\n```"));
}
#[test]
fn format_required_response_targets_mentions_turn_completeness() {
let diff =
"--- snapshot\n+++ document\n@@ -1 +1,2 @@\n+❯ Why were two prompts left unresolved?\n";
let rendered = format_required_response_targets(diff).unwrap();
assert!(rendered.contains("Do not stop at the newest question"));
assert!(rendered.contains("<target index=\"1\">"));
assert!(rendered.contains("❯ Why were two prompts left unresolved?"));
}
#[test]
fn format_prompt_bearing_changes_mentions_edit_and_artifact_contract() {
let diff = "--- snapshot\n+++ document\n@@ -1,3 +1,6 @@\n\
context before\n\
+❯ Why was this missed?\n\
+\n\
+This line should say 503.\n\
context after\n";
let rendered = format_prompt_bearing_changes(diff).unwrap();
assert!(rendered.contains("User-authored prompt-bearing changes (oldest first):"));
assert!(rendered.contains("kind=\"prompt_target\""));
assert!(rendered.contains("kind=\"content_edit\""));
assert!(rendered.contains("Treat `content_edit` items as user corrections"));
}
#[test]
fn prompt_prefix_normalization_targets_preserve_prompt_context_and_skip_fences() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,7 @@\n\
ctx\n\
+❯ In src/boost-client, why did patchback miss the prefix?\n\
+See my inquiry:\n\
+- keep this markdown bullet bare\n\
+ - keep nested markdown bullets bare\n\
+1. keep ordered markdown bullets bare\n\
+```text\n\
+line one\n\
+line two\n\
+```\n";
let targets = prompt_prefix_normalization_targets(diff);
assert_eq!(
targets,
vec!["See my inquiry:".to_string(),],
"only bare prompt-context prose should need fresh prefixing"
);
}
#[test]
fn first_bare_prompt_prefix_target_detects_unprefixed_prompt_block_line() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,5 @@\n\
ctx\n\
+❯ Existing question?\n\
+Follow-up context.\n\
+### Re: answer — gpt-5\n\
+Body\n";
let bare = first_bare_prompt_prefix_target(diff);
assert_eq!(bare.as_deref(), Some("Follow-up context."));
}
#[test]
fn first_bare_prompt_prefix_target_skips_markdown_lists() {
let diff = "--- snapshot\n+++ document\n@@ -1,2 +1,6 @@\n\
ctx\n\
+❯ Please compare these options:\n\
+- option one\n\
+ - nested option detail\n\
+1. ordered option\n\
+### Re: answer — gpt-5\n";
let bare = first_bare_prompt_prefix_target(diff);
assert_eq!(bare, None);
}
fn pbc(kind: PromptBearingChangeKind, text: &str) -> PromptBearingChange {
PromptBearingChange {
kind,
text: text.to_string(),
}
}
#[test]
fn change_is_managed_state_only_accepts_queue_activity_toggle() {
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"<!-- agent:queue auto -->"
)));
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"<!-- agent:queue -->"
)));
}
#[test]
fn change_is_managed_state_only_accepts_frontmatter_queue_active_flip() {
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"queue_active: true"
)));
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"queue_active: false"
)));
}
#[test]
fn pipeline_only_frontmatter_write_is_no_change() {
let snapshot = "---\nqueue: start\n---\n\n## Body\n- item\n";
let with_pipeline = "---\nqueue: start\nagent_doc_pipeline:\n run_id: cycle-123\n step: response_captured\n turn_id: \"#x\"\n---\n\n## Body\n- item\n";
assert!(
unified_diff_from_contents(snapshot, with_pipeline).is_none(),
"adding a pipeline block must not register as a change"
);
assert!(
unified_diff_from_contents(with_pipeline, snapshot).is_none(),
"clearing a pipeline block must not register as a change"
);
let with_pipeline_and_edit = "---\nqueue: start\nagent_doc_pipeline:\n run_id: cycle-123\n step: committed\n---\n\n## Body\n- item changed\n";
assert!(
unified_diff_from_contents(snapshot, with_pipeline_and_edit).is_some(),
"a real body edit must still be detected through a pipeline write"
);
}
#[test]
fn change_is_managed_state_only_accepts_pipeline_block_lines() {
for line in [
"agent_doc_pipeline:",
" run_id: cycle-123",
" step: write_applied",
" turn_id: \"#x\"",
" queue_task_id: \"#x\"",
] {
assert!(
change_is_managed_state_only(&pbc(PromptBearingChangeKind::ContentEdit, line)),
"pipeline line should be managed state: {line:?}"
);
}
}
#[test]
fn change_is_managed_state_only_accepts_queue_item_lines() {
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::PromptTarget,
"- do [#newitem]"
)));
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::PromptTarget,
"- ~do [#consumed]~"
)));
}
#[test]
fn change_is_managed_state_only_accepts_backlog_and_done_item_lines() {
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"- [ ] [#newitem] short description"
)));
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"- [/] [#gated] partial progress note"
)));
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"- 2026-05-25 [#done] closed last cycle"
)));
}
#[test]
fn change_is_managed_state_only_accepts_multi_line_managed_blocks() {
assert!(change_is_managed_state_only(&pbc(
PromptBearingChangeKind::PromptTarget,
"- do [#a]\n- do [#b]\n- do [#c]"
)));
}
#[test]
fn change_is_managed_state_only_rejects_real_user_prompts() {
assert!(!change_is_managed_state_only(&pbc(
PromptBearingChangeKind::PromptTarget,
"Why is the queue not auto-looping?"
)));
assert!(!change_is_managed_state_only(&pbc(
PromptBearingChangeKind::PromptTarget,
"❯ do this thing please"
)));
assert!(!change_is_managed_state_only(&pbc(
PromptBearingChangeKind::ContentEdit,
"Fix the regression on line 42."
)));
}
#[test]
fn change_is_managed_state_only_rejects_mixed_managed_and_user_text() {
assert!(!change_is_managed_state_only(&pbc(
PromptBearingChangeKind::PromptTarget,
"- do [#newitem]\nAnd please also fix the older bug."
)));
}
#[test]
fn line_is_binary_authored_compact_summary_recognizes_summary_shapes() {
for line in [
"### Session Summary",
"❯ ### Session Summary",
"Compacted content:",
"*Compacted. Content archived to `.agent-doc/archives/x.md`*",
"❯ *Compacted. Content archived to `.agent-doc/archives/x.md`*",
"- Archived 6 response topic(s): a; b; c; 3 more",
"❯ - Archived 6 response topic(s): a; b; c; 3 more",
"- Prior summary/context: prior compacted content: ...",
"- Trailing prompt/context: leftover prose",
] {
assert!(
line_is_binary_authored_compact_summary(line),
"should recognize binary-authored summary line: {line:?}"
);
}
for line in [
"Why did the compaction archive my queue items?",
"do #provauth3",
"- Archived the old plan, please review",
"Compacted the notes manually — is that ok?",
] {
assert!(
!line_is_binary_authored_compact_summary(line),
"must not match a real user line: {line:?}"
);
}
}
#[test]
fn compact_summary_replacement_is_not_a_prompt_target() {
let prev = "<!-- agent:exchange patch=append -->\n\
### Re: old topic - gpt-5\n\nA long archived answer body.\n\n\
<!-- agent:boundary:old -->\n<!-- /agent:exchange -->\n";
let current = "<!-- agent:exchange patch=append -->\n\
### Session Summary\n\n\
*Compacted. Content archived to `.agent-doc/archives/x.md`*\n\n\
Compacted content:\n\
- Archived 6 response topic(s): a; b; c; 3 more\n\
- Prior summary/context: earlier work\n\
<!-- agent:boundary:new -->\n<!-- /agent:exchange -->\n";
let diff = unified_diff_from_contents(prev, current).expect("diff");
let changes = classify_prompt_bearing_changes(&diff);
assert!(
changes
.iter()
.all(|c| c.kind != PromptBearingChangeKind::PromptTarget),
"compaction summary lines must not be PromptTargets: {changes:?}"
);
}
}