use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::HashSet;
use crate::component::{self, Component, find_comment_end, is_backlog_component};
#[derive(Debug, Clone)]
pub struct PatchBlock {
pub name: String,
pub content: String,
#[allow(dead_code)]
pub attrs: std::collections::HashMap<String, String>,
}
impl PatchBlock {
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
PatchBlock {
name: name.into(),
content: content.into(),
attrs: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Serialize)]
pub struct TemplateInfo {
pub template_mode: bool,
pub components: Vec<ComponentInfo>,
}
#[derive(Debug, Serialize)]
pub struct ComponentInfo {
pub name: String,
pub mode: String,
pub content: String,
pub line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_entries: Option<usize>,
}
#[cfg(test)]
pub fn is_template_mode(mode: Option<&str>) -> bool {
matches!(mode, Some("template"))
}
pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
let bytes = response.as_bytes();
let len = bytes.len();
let code_ranges = component::find_code_ranges(response);
let mut patches = Vec::new();
let mut unmatched = String::new();
let mut pos = 0;
let mut last_end = 0;
while pos + 4 <= len {
if &bytes[pos..pos + 4] != b"<!--" {
pos += 1;
continue;
}
if code_ranges
.iter()
.any(|&(start, end)| pos >= start && pos < end)
{
pos += 4;
continue;
}
let marker_start = pos;
let close = match find_comment_end(bytes, pos + 4) {
Some(c) => c,
None => {
pos += 4;
continue;
}
};
let inner = &response[marker_start + 4..close - 3];
let trimmed = inner.trim();
let parsed_prefix: Option<(&str, &str)> = if let Some(rest) = trimmed.strip_prefix("patch:")
{
Some(("patch", rest))
} else if let Some(rest) = trimmed.strip_prefix("replace:") {
let rest_trim = rest.trim_start();
let name_end = rest_trim
.find(|c: char| c.is_whitespace())
.unwrap_or(rest_trim.len());
let name = &rest_trim[..name_end];
if is_backlog_component(name)
|| name == component::ICEBOX_COMPONENT
|| name == component::REVIEW_COMPONENT
{
Some(("replace", rest))
} else {
None
}
} else {
None
};
if let Some((prefix_kind, rest)) = parsed_prefix {
let rest = rest.trim();
if rest.is_empty() || rest.starts_with('/') {
pos = close;
continue;
}
let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
let name = &rest[..space_idx];
let attr_text = rest[space_idx..].trim();
(name, component::parse_attrs(attr_text))
} else {
(rest, std::collections::HashMap::new())
};
if prefix_kind == "patch" && is_backlog_component(name) {
eprintln!(
"warning: `<!-- patch:{} -->` is deprecated — use `<!-- replace:{} -->` instead (see #25ag)",
name, name
);
}
let mut content_start = close;
if content_start < len && bytes[content_start] == b'\n' {
content_start += 1;
}
let before = &response[last_end..marker_start];
let trimmed_before = before.trim();
if !trimmed_before.is_empty() {
if !unmatched.is_empty() {
unmatched.push('\n');
}
unmatched.push_str(trimmed_before);
}
let close_marker = format!("<!-- /{}:{} -->", prefix_kind, name);
if let Some(close_pos) =
find_outside_code(&close_marker, response, content_start, &code_ranges)
{
let content = &response[content_start..close_pos];
patches.push(PatchBlock {
name: name.to_string(),
content: content.to_string(),
attrs,
});
let mut end = close_pos + close_marker.len();
if end < len && bytes[end] == b'\n' {
end += 1;
}
last_end = end;
pos = end;
continue;
}
let mut orphan_end = close;
if orphan_end < len && bytes[orphan_end] == b'\n' {
orphan_end += 1;
}
last_end = orphan_end;
pos = orphan_end;
continue;
}
pos = close;
}
if last_end < len {
let trailing = response[last_end..].trim();
if !trailing.is_empty() {
if !unmatched.is_empty() {
unmatched.push('\n');
}
unmatched.push_str(trailing);
}
}
Ok((patches, unmatched))
}
pub fn apply_patches_pure(
doc: &str,
patches: &[PatchBlock],
unmatched: &str,
summary: Option<&str>,
component_configs: &std::collections::HashMap<String, String>,
max_lines_configs: &std::collections::HashMap<String, usize>,
) -> Result<String> {
apply_patches_with_overrides_pure(
doc,
patches,
unmatched,
summary,
component_configs,
max_lines_configs,
&std::collections::HashMap::new(),
)
}
mod tail_repair;
pub use tail_repair::*;
pub fn normalize_editor_visible_template_structure(doc: &str) -> Result<String> {
if let Some(reason) = crate::component::malformed_agent_comment_reason(doc) {
anyhow::bail!("template structural corruption guard failed: {reason}");
}
let mut normalized = crate::component::strip_backlog_patch_attr(doc);
if let Some(repaired) = repair_queue_struck_items_escaped_below_marker(&normalized) {
normalized = repaired;
}
while let Some(merged) = repair_duplicate_exchange_opener(&normalized)? {
normalized = merged;
}
if let Some(cleaned) = remove_duplicate_answered_exchange_prompt_tail(&normalized) {
normalized = cleaned;
}
guard_no_duplicate_prompt_residue_outside_exchange(&normalized)
.context("template duplicate prompt residue guard failed")?;
match guard_no_conversation_tail_outside_exchange(&normalized) {
Ok(()) => {
guard_editor_visible_structural_corruption(&normalized)?;
Ok(normalized)
}
Err(err)
if err.chain().any(|cause| {
cause
.to_string()
.contains("closing marker <!-- /agent:exchange --> without matching open")
}) =>
{
if let Some(repaired) = repair_duplicate_exchange_close_scaffold(&normalized)? {
guard_no_duplicate_prompt_residue_outside_exchange(&repaired)
.context("template duplicate prompt residue guard failed after duplicate-scaffold repair")?;
guard_no_conversation_tail_outside_exchange(&repaired).with_context(
|| "template structure guard failed after duplicate-scaffold repair",
)?;
guard_editor_visible_structural_corruption(&repaired)?;
return Ok(repaired);
}
if repair_duplicate_exchange_close_mixed_scaffold_tail(&normalized)?.is_some() {
anyhow::bail!(
"mixed duplicate scaffold tail: live conversation text is interleaved with duplicated template scaffold; refusing automatic visible-document repair"
);
}
if let Some(repaired) = repair_duplicate_exchange_close_tail(&normalized)? {
guard_no_duplicate_prompt_residue_outside_exchange(&repaired).context(
"template duplicate prompt residue guard failed after duplicate-close repair",
)?;
guard_no_conversation_tail_outside_exchange(&repaired).with_context(
|| "template structure guard failed after duplicate-close repair",
)?;
guard_editor_visible_structural_corruption(&repaired)?;
return Ok(repaired);
}
Err(err).context("template structure guard failed")
}
Err(err) => Err(err).context("template structure guard failed"),
}
}
fn guard_editor_visible_structural_corruption(doc: &str) -> Result<()> {
if let Some(reason) = crate::component::structural_corruption_reason(doc) {
anyhow::bail!("template structural corruption guard failed: {reason}");
}
Ok(())
}
fn is_displaced_struck_queue_item(line: &str) -> bool {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix("- ") else {
return false;
};
let rest = rest.trim();
let Some(inner) = rest.strip_prefix("~~").and_then(|s| s.strip_suffix("~~")) else {
return false;
};
let inner = inner.trim();
inner.contains(":round_pushpin:")
|| inner.contains(":pushpin:")
|| inner.starts_with("do [#")
|| inner.starts_with("do #")
|| inner.starts_with("re [#")
|| inner.starts_with("re #")
}
pub fn repair_queue_struck_items_escaped_below_marker(doc: &str) -> Option<String> {
let components = component::parse(doc).ok()?;
let queue = components.iter().find(|c| c.name == "queue")?;
let scan_start = queue.close_end;
let protected: Vec<(usize, usize)> = components
.iter()
.map(|c| (c.open_start, c.close_end))
.collect();
let mut remove_ranges: Vec<(usize, usize)> = Vec::new();
let mut offset = scan_start;
for line in doc[scan_start..].split_inclusive('\n') {
let line_start = offset;
let line_end = offset + line.len();
offset = line_end;
let inside_component = protected
.iter()
.any(|(start, end)| line_start >= *start && line_end <= *end);
if inside_component {
continue;
}
if is_displaced_struck_queue_item(line) {
remove_ranges.push((line_start, line_end));
}
}
if remove_ranges.is_empty() {
return None;
}
let mut out = String::with_capacity(doc.len());
let mut cursor = 0usize;
for (start, end) in &remove_ranges {
out.push_str(&doc[cursor..*start]);
cursor = *end;
}
out.push_str(&doc[cursor..]);
Some(out)
}
mod prompt_comment_repair;
pub use prompt_comment_repair::*;
pub fn repair_duplicate_exchange_opener(doc: &str) -> Result<Option<String>> {
let components = match component::parse(doc) {
Ok(c) => c,
Err(_) => return Ok(None),
};
let exchanges: Vec<&Component> = components.iter().filter(|c| c.name == "exchange").collect();
if exchanges.len() < 2 {
return Ok(None);
}
let first = exchanges[0];
let second = exchanges[1];
let first_content = first.content(doc).trim_end().to_string();
let second_content = second.content(doc).trim().to_string();
let merged = if first_content.trim().is_empty() && second_content.is_empty() {
"\n".to_string()
} else if first_content.trim().is_empty() {
format!("{}\n", second_content)
} else if second_content.is_empty() {
format!("{}\n", first_content)
} else {
format!("{}\n{}\n", first_content, second_content)
};
let mut result = String::new();
result.push_str(&doc[..first.open_end]);
result.push_str(&merged);
result.push_str(&doc[first.close_start..second.open_start]);
result.push_str(&doc[second.close_end..]);
if exchanges.len() > 2 {
eprintln!(
"[template] {} exchange components found — merged first two, {} remain",
exchanges.len(),
exchanges.len() - 1
);
}
Ok(Some(result))
}
pub fn strip_conversation_tail_outside_exchange(doc: &str) -> Result<Option<String>> {
let components = component::parse(doc).context("failed to parse components")?;
let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
return Ok(None);
};
let Some((tail_start, tail_end)) =
escaped_conversation_range_outside_exchange(doc, &components, exchange)
else {
return Ok(None);
};
let tail = &doc[tail_start..tail_end];
if !tail_is_safe_exchange_content(tail) {
anyhow::bail!(
"conversation content escaped `agent:exchange`, but the trailing document structure is ambiguous"
);
}
let mut stripped = String::with_capacity(doc.len() - (tail_end - tail_start));
stripped.push_str(&doc[..tail_start]);
stripped.push_str(&doc[tail_end..]);
Ok(Some(stripped))
}
pub fn deleted_conversation_tail_cleanup(before: &str, after: &str) -> Result<Option<String>> {
if before == after || after.len() >= before.len() {
return Ok(None);
}
if let Some(stripped) = strip_conversation_tail_outside_exchange(before)?
&& stripped == after
{
return Ok(Some(stripped));
}
let Some(deleted) = single_deleted_range(before, after) else {
return Ok(None);
};
let components = component::parse(before).context("failed to parse components")?;
let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
return Ok(None);
};
if deleted.start < exchange.close_end {
return Ok(None);
}
if components
.iter()
.any(|component| component.open_start < deleted.end && component.close_end > deleted.start)
{
return Ok(None);
}
let tail = &before[deleted.clone()];
if !tail_is_safe_exchange_content(tail) || !tail_contains_exchange_signal(tail) {
return Ok(None);
}
Ok(Some(after.to_string()))
}
fn single_deleted_range(before: &str, after: &str) -> Option<std::ops::Range<usize>> {
let prefix = common_prefix_boundary_len(before, after);
let mut before_end = before.len();
let mut after_end = after.len();
while before_end > prefix && after_end > prefix {
let (before_prev, before_ch) = previous_char(before, before_end)?;
let (after_prev, after_ch) = previous_char(after, after_end)?;
if before_ch != after_ch {
break;
}
before_end = before_prev;
after_end = after_prev;
}
if prefix >= before_end {
return None;
}
let mut reconstructed = String::with_capacity(after.len());
reconstructed.push_str(&before[..prefix]);
reconstructed.push_str(&before[before_end..]);
if reconstructed == after {
Some(prefix..before_end)
} else {
None
}
}
fn common_prefix_boundary_len(a: &str, b: &str) -> usize {
let mut len = 0usize;
for ((a_idx, a_ch), (_b_idx, b_ch)) in a.char_indices().zip(b.char_indices()) {
if a_ch != b_ch {
break;
}
len = a_idx + a_ch.len_utf8();
}
len
}
fn previous_char(s: &str, end: usize) -> Option<(usize, char)> {
s.get(..end)?.char_indices().next_back()
}
fn tail_contains_exchange_signal(tail: &str) -> bool {
tail.lines().any(|line| {
let trimmed = line.trim();
is_exchange_turn_heading(trimmed)
|| text_line_looks_like_prompt_target(trimmed)
|| line_looks_like_prompt_preset_reference(trimmed)
})
}
fn line_looks_like_prompt_preset_reference(line: &str) -> bool {
let trimmed = line
.trim_start_matches('❯')
.trim_start()
.trim_start_matches("- ")
.trim_start_matches("* ")
.trim_start_matches("+ ")
.trim_start();
let Some(rest) = trimmed.strip_prefix('#') else {
return false;
};
let id_len = rest
.char_indices()
.take_while(|(_, ch)| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_')
.map(|(idx, ch)| idx + ch.len_utf8())
.last()
.unwrap_or(0);
id_len > 0
&& rest[id_len..]
.chars()
.next()
.is_none_or(|ch| ch.is_whitespace())
}
pub(crate) fn strip_trailing_caret_lines(content: &str) -> String {
let trailing_nl = content.ends_with('\n');
let mut lines: Vec<&str> = content.split('\n').collect();
if trailing_nl {
lines.pop();
}
while let Some(last) = lines.last() {
let t = last.trim();
if t == "❯" {
lines.pop();
} else {
break;
}
}
let mut out = lines.join("\n");
if trailing_nl {
out.push('\n');
}
out
}
#[derive(Debug, Clone)]
struct PromptTailBlock {
text: String,
end: usize,
pending_ids: HashSet<String>,
}
fn is_exchange_response_heading(trimmed: &str) -> bool {
trimmed.starts_with("### Re:")
|| trimmed.starts_with("#### Re:")
|| trimmed.starts_with("##### Re:")
}
fn extract_pending_ids(text: &str) -> HashSet<String> {
let mut ids = HashSet::new();
let chars: Vec<char> = text.chars().collect();
let mut idx = 0usize;
while idx < chars.len() {
if chars[idx] != '#' {
idx += 1;
continue;
}
let start = idx + 1;
let mut end = start;
while end < chars.len() {
let ch = chars[end];
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
end += 1;
} else {
break;
}
}
if end > start {
let id = chars[start..end].iter().collect::<String>();
if is_valid_pending_like_id(&id) {
ids.insert(id);
}
}
idx = end.max(idx + 1);
}
ids
}
fn is_valid_pending_like_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}
fn text_line_looks_like_prompt_target(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with("<!--")
&& !trimmed.starts_with("```")
&& !trimmed.starts_with("~~~")
&& !is_exchange_response_heading(trimmed)
&& (trimmed.starts_with('❯')
|| trimmed.ends_with('?')
|| looks_like_prompt_directive(trimmed))
}
fn looks_like_prompt_directive(line: &str) -> bool {
let lower = line.trim_start_matches('❯').trim().to_ascii_lowercase();
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 ")
}
fn unresolved_tail_after_boundary(doc: &str, component_name: &str) -> Option<String> {
let components = component::parse(doc).ok()?;
let comp = components.iter().find(|comp| comp.name == component_name)?;
let prefix = "<!-- agent:boundary:";
let content_region = &doc[comp.open_end..comp.close_start];
let code_ranges = component::find_code_ranges(doc);
let mut search_from = 0usize;
while let Some(start) = content_region[search_from..].find(prefix) {
let abs_start = comp.open_end + search_from + start;
if code_ranges
.iter()
.any(|&(cs, ce)| abs_start >= cs && abs_start < ce)
{
search_from += start + prefix.len();
continue;
}
let line_start = search_from + start;
let marker_end = content_region[line_start..]
.find('\n')
.map(|idx| line_start + idx + 1)
.unwrap_or(content_region.len());
return Some(content_region[marker_end..].to_string());
}
None
}
fn extract_prompt_tail_blocks(text: &str) -> Vec<PromptTailBlock> {
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 block_start(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with("<!--")
&& !trimmed.starts_with("```")
&& !trimmed.starts_with("~~~")
&& !is_exchange_response_heading(trimmed)
&& text_line_looks_like_prompt_target(trimmed)
}
fn push_block(blocks: &mut Vec<PromptTailBlock>, text: &str, start: Option<usize>, end: usize) {
let Some(start) = start else {
return;
};
let raw = &text[start..end];
let trimmed = raw.trim();
if trimmed.is_empty() {
return;
}
blocks.push(PromptTailBlock {
text: trimmed.to_string(),
end,
pending_ids: extract_pending_ids(trimmed),
});
}
let mut blocks = Vec::new();
let mut current_start = None;
let mut current_end = 0usize;
let mut current_has_blank = false;
let mut in_fence = false;
let mut fence_char = '`';
let mut fence_len = 3usize;
let mut offset = 0usize;
for segment in text.split_inclusive('\n') {
let line = segment.trim_end_matches('\n');
let trimmed = line.trim();
let starts_new = current_start.is_some()
&& !in_fence
&& ((current_has_blank && !trimmed.is_empty()) || trimmed.starts_with('❯'))
&& block_start(line);
if starts_new {
push_block(&mut blocks, text, current_start.take(), current_end);
current_end = 0;
current_has_blank = false;
}
if current_start.is_none() {
if !block_start(line) {
offset += segment.len();
continue;
}
current_start = Some(offset);
current_has_blank = false;
}
current_end = offset + segment.len();
if trimmed.is_empty() {
current_has_blank = true;
}
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;
}
offset += segment.len();
}
push_block(&mut blocks, text, current_start, current_end);
blocks
}
fn find_tail_anchor_block<'a>(
response_content: &str,
prompt_blocks: &'a [PromptTailBlock],
) -> Result<Option<&'a PromptTailBlock>> {
if prompt_blocks.is_empty() {
return Ok(None);
}
let response_ids = extract_pending_ids(response_content);
let candidate_idx = if response_ids.is_empty() {
0
} else if let Some(idx) = prompt_blocks
.iter()
.position(|block| !block.pending_ids.is_disjoint(&response_ids))
{
idx
} else if prompt_blocks.len() == 1 {
0
} else {
anyhow::bail!(
"exchange patchback is ambiguous: response references pending ids {:?}, but none of the unresolved prompt blocks match",
response_ids
);
};
if candidate_idx > 0 {
anyhow::bail!(
"exchange patchback would skip older unresolved prompt(s): oldest pending block is `{}`, but the response matches later block `{}`",
prompt_blocks[0].text.lines().next().unwrap_or("(empty)"),
prompt_blocks[candidate_idx]
.text
.lines()
.next()
.unwrap_or("(empty)")
);
}
Ok(prompt_blocks.get(candidate_idx))
}
fn append_exchange_patch_after_prompt_anchor(
original_doc: &str,
current_doc: &str,
comp: &Component,
patch_content: &str,
) -> Result<Option<String>> {
let Some(original_tail) = unresolved_tail_after_boundary(original_doc, &comp.name) else {
return Ok(None);
};
let prompt_blocks = extract_prompt_tail_blocks(&original_tail);
let Some(anchor) = find_tail_anchor_block(patch_content, &prompt_blocks)? else {
return Ok(None);
};
let content_region = comp.content(current_doc);
let boundary_marker = find_boundary_in_component(current_doc, comp)
.map(|id| format!("<!-- agent:boundary:{id} -->"))
.context("exchange append anchor missing boundary marker")?;
let boundary_pos = content_region
.find(&boundary_marker)
.context("exchange append anchor boundary marker not found in current content")?;
let boundary_line_end = content_region[boundary_pos..]
.find('\n')
.map(|idx| boundary_pos + idx + 1)
.unwrap_or(content_region.len());
let user_region = &content_region[..boundary_pos];
let insert_at = match user_region.rfind(&original_tail) {
Some(tail_start) => tail_start + anchor.end,
None if prompt_blocks.len() == 1 => user_region.trim_end().len(),
None => anyhow::bail!(
"exchange patchback prompt drifted from the baseline ({} unresolved prompt block(s) in the current tail); re-run `agent-doc preflight` to refresh the baseline before closeout. anchor: {}",
prompt_blocks.len(),
anchor.text.lines().next().unwrap_or("(empty)")
),
};
let new_id = crate::id::new_boundary_id();
let new_marker = crate::id::format_boundary_marker(&new_id);
let mut new_content =
String::with_capacity(content_region.len() + patch_content.len() + new_marker.len() + 2);
new_content.push_str(&user_region[..insert_at]);
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str(patch_content.trim_end());
new_content.push('\n');
new_content.push_str(&new_marker);
new_content.push('\n');
new_content.push_str(&user_region[insert_at..]);
new_content.push_str(&content_region[boundary_line_end..]);
Ok(Some(comp.replace_content(current_doc, &new_content)))
}
pub fn apply_patches_with_overrides_pure(
doc: &str,
patches: &[PatchBlock],
unmatched: &str,
summary: Option<&str>,
component_configs: &std::collections::HashMap<String, String>,
max_lines_configs: &std::collections::HashMap<String, usize>,
mode_overrides: &std::collections::HashMap<String, String>,
) -> Result<String> {
let mut result = remove_all_boundaries(doc);
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
let id = crate::id::new_boundary_id_with_summary(summary);
let marker = crate::id::format_boundary_marker(&id);
let content = exchange.content(&result);
let new_content = format!("{}\n{}\n", content.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
eprintln!(
"[template] pre-patch boundary {} inserted at end of exchange",
id
);
}
let components = component::parse(&result).context("failed to parse components")?;
let configs = component_configs;
let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
let mut overflow = String::new();
for patch in patches {
if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
ops.push((idx, patch));
} else {
let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
eprintln!(
"[template] patch target '{}' not found, routing to exchange/output. Available: {}",
patch.name,
available.join(", ")
);
if !overflow.is_empty() {
overflow.push('\n');
}
overflow.push_str(&patch.content);
}
}
ops.sort_by_key(|op| std::cmp::Reverse(op.0));
for (idx, patch) in &ops {
let comp = &components[*idx];
let mode = mode_overrides
.get(&patch.name)
.map(|s| s.as_str())
.or_else(|| comp.patch_mode())
.or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
.unwrap_or_else(|| default_mode(&patch.name));
let patch_content: std::borrow::Cow<'_, str> = if patch.name == "exchange" {
std::borrow::Cow::Owned(strip_trailing_caret_lines(&patch.content))
} else {
std::borrow::Cow::Borrowed(patch.content.as_str())
};
if mode == "append"
&& let Some(bid) = find_boundary_in_component(&result, comp)
{
if patch.name == "exchange"
&& patch_content
.lines()
.any(|line| is_exchange_response_heading(line.trim_start()))
&& let Some(anchored) =
append_exchange_patch_after_prompt_anchor(doc, &result, comp, &patch_content)?
{
result = anchored;
continue;
}
result = comp.append_with_boundary(&result, &patch_content, &bid);
continue;
}
let new_content = apply_mode(mode, comp.content(&result), &patch_content);
result = comp.replace_content(&result, &new_content);
}
let mut all_unmatched = String::new();
let exchange_patch_present = patches.iter().any(|patch| patch.name == "exchange");
if !overflow.is_empty() {
all_unmatched.push_str(&overflow);
}
if !unmatched.is_empty() {
if !all_unmatched.is_empty() {
all_unmatched.push('\n');
}
all_unmatched.push_str(unmatched);
}
if !all_unmatched.is_empty() {
let components =
component::parse(&result).context("failed to re-parse components after patching")?;
if let Some(output_comp) = components
.iter()
.find(|c| c.name == "exchange" || c.name == "output")
{
let stripped = if output_comp.name == "exchange" {
strip_trailing_caret_lines(&all_unmatched)
} else {
all_unmatched.clone()
};
let unmatched = &stripped;
let force_replace = output_comp.name == "exchange"
&& mode_overrides
.get("exchange")
.map(|mode| mode == "replace")
.unwrap_or(false)
&& !exchange_patch_present;
if force_replace {
let new_content = format!("{}\n", unmatched.trim_end());
result = output_comp.replace_content(&result, &new_content);
} else if let Some(bid) = find_boundary_in_component(&result, output_comp) {
eprintln!(
"[template] unmatched content: using boundary {} for insertion",
&bid[..bid.len().min(8)]
);
result = output_comp.append_with_boundary(&result, unmatched, &bid);
} else {
let existing = output_comp.content(&result);
let new_content = if existing.trim().is_empty() {
format!("{}\n", unmatched)
} else {
format!("{}{}\n", existing, unmatched)
};
result = output_comp.replace_content(&result, &new_content);
}
} else {
let stripped = strip_trailing_caret_lines(&all_unmatched);
if !result.ends_with('\n') {
result.push('\n');
}
result.push_str("\n<!-- agent:exchange -->\n");
result.push_str(&stripped);
result.push_str("\n<!-- /agent:exchange -->\n");
}
}
while let Some(merged) = repair_duplicate_exchange_opener(&result)? {
eprintln!("[template] post-patch: merged duplicate exchange opener");
result = merged;
}
result = dedup_exchange_adjacent_lines(&result);
{
'stability: for _ in 0..3 {
let Ok(components) = component::parse(&result) else {
break;
};
for comp in &components {
let max_lines = comp
.attrs
.get("max_lines")
.and_then(|s| s.parse::<usize>().ok())
.or_else(|| max_lines_configs.get(&comp.name).copied())
.unwrap_or(0);
if max_lines > 0 {
let content = comp.content(&result);
let trimmed = limit_lines(content, max_lines);
if trimmed.len() != content.len() {
let trimmed = format!("{}\n", trimmed.trim_end());
result = comp.replace_content(&result, &trimmed);
continue 'stability;
}
}
}
break; }
}
{
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
&& find_boundary_in_component(&result, exchange).is_none()
{
let id = uuid::Uuid::new_v4().to_string();
let marker = format!("<!-- agent:boundary:{} -->", id);
let content = exchange.content(&result);
let new_content = format!("{}\n{}\n", content.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
eprintln!(
"[template] re-inserted boundary {} at end of exchange",
&id[..id.len().min(8)]
);
}
}
result = annotate_exchange_headings_against_baseline(&result, doc);
Ok(result)
}
pub fn reposition_boundary_to_end(doc: &str) -> String {
reposition_boundary_to_end_with_summary(doc, None)
}
pub fn reposition_boundary_to_end_clean(doc: &str) -> String {
reposition_boundary_to_end_clean_with_id(doc, None)
}
pub fn reposition_boundary_to_end_clean_with_id(doc: &str, boundary_id: Option<&str>) -> String {
reposition_boundary_to_end_clean_internal(doc, boundary_id, None)
}
pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
reposition_boundary_to_end_with_baseline(doc, summary, None)
}
pub fn reposition_boundary_to_end_clean_with_summary(doc: &str, summary: Option<&str>) -> String {
reposition_boundary_to_end_clean_internal(doc, None, summary)
}
pub fn reposition_boundary_to_end_clean_with_summary_and_id(
doc: &str,
boundary_id: Option<&str>,
summary: Option<&str>,
) -> String {
reposition_boundary_to_end_clean_internal(doc, boundary_id, summary)
}
fn reposition_boundary_to_end_clean_internal(
doc: &str,
boundary_id: Option<&str>,
summary: Option<&str>,
) -> String {
let mut result = strip_transient_head_markers(&remove_all_boundaries(doc));
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
let id = boundary_id
.map(ToOwned::to_owned)
.unwrap_or_else(|| crate::id::new_boundary_id_with_summary(summary));
let marker = crate::id::format_boundary_marker(&id);
let content = exchange.content(&result).to_string();
let new_content = format!("{}\n{}\n", content.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
}
result
}
pub fn reposition_boundary_to_end_preserve_head(doc: &str) -> String {
reposition_boundary_to_end_preserve_head_with_id(doc, None)
}
pub fn reposition_boundary_to_end_preserve_head_with_id(
doc: &str,
boundary_id: Option<&str>,
) -> String {
let mut result = remove_all_boundaries(doc);
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
let id = boundary_id
.map(ToOwned::to_owned)
.unwrap_or_else(crate::id::new_boundary_id);
let marker = crate::id::format_boundary_marker(&id);
let content = exchange.content(&result).to_string();
let new_content = format!("{}\n{}\n", content.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
}
result
}
fn strip_transient_head_markers(content: &str) -> String {
let code_ranges = component::find_code_ranges(content);
let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
let mut result = String::with_capacity(content.len());
let mut offset = 0usize;
for line in content.split_inclusive('\n') {
let line_start = offset;
offset += line.len();
let mut rewritten = line.to_string();
if !in_code(line_start) {
let had_newline = rewritten.ends_with('\n');
let body = rewritten.trim_end_matches('\n').trim_end_matches('\r');
let trimmed = body.trim_start();
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
let is_markdown_heading =
hash_count > 0 && hash_count <= 6 && trimmed[hash_count..].starts_with(' ');
let is_bold_pseudo_header = trimmed.starts_with("**") && trimmed.ends_with("** (HEAD)");
if (is_markdown_heading || is_bold_pseudo_header)
&& let Some(stripped) = body.strip_suffix(" (HEAD)")
{
rewritten = if had_newline {
format!("{stripped}\n")
} else {
stripped.to_string()
};
}
}
result.push_str(&rewritten);
}
result
}
pub fn reposition_boundary_to_end_with_baseline(
doc: &str,
summary: Option<&str>,
baseline_headings: Option<&std::collections::HashSet<String>>,
) -> String {
let mut result = remove_all_boundaries(doc);
if let Ok(components) = component::parse(&result)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
let id = crate::id::new_boundary_id_with_summary(summary);
let marker = crate::id::format_boundary_marker(&id);
let content = exchange.content(&result).to_string();
let annotated = annotate_re_headings_with_head(&content, baseline_headings);
let new_content = format!("{}\n{}\n", annotated.trim_end(), marker);
result = exchange.replace_content(&result, &new_content);
}
result
}
pub fn exchange_baseline_headings(doc: &str) -> std::collections::HashSet<String> {
if let Ok(components) = component::parse(doc)
&& let Some(exchange) = components.iter().find(|c| c.name == "exchange")
{
return collect_re_headings(exchange.content(doc));
}
std::collections::HashSet::new()
}
pub fn annotate_exchange_headings_against_baseline(doc: &str, baseline_doc: &str) -> String {
let Ok(components) = component::parse(doc) else {
return doc.to_string();
};
let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
return doc.to_string();
};
let baseline_exchange = component::parse(baseline_doc)
.ok()
.and_then(|components| {
components
.iter()
.find(|c| c.name == "exchange")
.map(|c| c.content(baseline_doc).to_string())
})
.unwrap_or_default();
let baseline_headings = exchange_baseline_headings(baseline_doc);
let content = exchange.content(doc);
let normalize_for_compare = |value: &str| {
strip_transient_head_markers(value)
.lines()
.filter(|line| !line.trim().starts_with("<!-- agent:boundary:"))
.collect::<Vec<_>>()
.join("\n")
};
if normalize_for_compare(content) == normalize_for_compare(&baseline_exchange) {
return doc.to_string();
}
let annotated = annotate_re_headings_with_head(content, Some(&baseline_headings));
if annotated == content {
return doc.to_string();
}
exchange.replace_content(doc, &annotated)
}
fn collect_re_headings(content: &str) -> std::collections::HashSet<String> {
let code_ranges = component::find_code_ranges(content);
let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
let mut set = std::collections::HashSet::new();
let mut offset = 0usize;
for line in content.split_inclusive('\n') {
let line_start = offset;
offset += line.len();
if in_code(line_start) {
continue;
}
let body = line.trim_end_matches('\n').trim_end_matches('\r');
let trimmed = body.trim_start();
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
continue;
}
let after_hash = &trimmed[hash_count..];
if !after_hash.starts_with(' ') {
continue;
}
if !after_hash.trim_start().starts_with("Re:") {
continue;
}
let stripped = body
.trim_start()
.trim_end()
.trim_end_matches(" (HEAD)")
.to_string();
set.insert(stripped);
}
set
}
pub(crate) fn annotate_re_headings_with_head(
content: &str,
baseline: Option<&std::collections::HashSet<String>>,
) -> String {
let code_ranges = component::find_code_ranges(content);
let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
let mut lines: Vec<String> = content
.split_inclusive('\n')
.map(|s| s.to_string())
.collect();
let mut re_indices: Vec<usize> = Vec::new();
let mut offset = 0usize;
for (idx, line) in lines.iter_mut().enumerate() {
let line_start = offset;
offset += line.len();
if in_code(line_start) {
continue;
}
let had_newline = line.ends_with('\n');
let body_ref = line.trim_end_matches('\n').trim_end_matches('\r');
let trimmed = body_ref.trim_start();
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
continue;
}
let after_hash = &trimmed[hash_count..];
if !after_hash.starts_with(' ') {
continue;
}
if !after_hash.trim_start().starts_with("Re:") {
continue;
}
let stripped = body_ref.trim_end().trim_end_matches(" (HEAD)");
*line = if had_newline {
format!("{stripped}\n")
} else {
stripped.to_string()
};
re_indices.push(idx);
}
let mark_indices: Vec<usize> = match baseline {
Some(baseline_set) => {
let filtered: Vec<usize> = re_indices
.iter()
.copied()
.filter(|&idx| {
let line = &lines[idx];
let key = line
.trim_end_matches('\n')
.trim_end_matches('\r')
.trim_start()
.trim_end();
!baseline_set.contains(key)
})
.collect();
if filtered.is_empty() {
re_indices.last().copied().into_iter().collect()
} else {
filtered
}
}
None => re_indices.last().copied().into_iter().collect(),
};
for idx in mark_indices {
let line = &lines[idx];
let had_newline = line.ends_with('\n');
let body = line.trim_end_matches('\n').trim_end_matches('\r');
lines[idx] = if had_newline {
format!("{body} (HEAD)\n")
} else {
format!("{body} (HEAD)")
};
}
lines.concat()
}
fn remove_all_boundaries(doc: &str) -> String {
let prefix = "<!-- agent:boundary:";
let suffix = " -->";
let code_ranges = component::find_code_ranges(doc);
let in_code = |pos: usize| {
code_ranges
.iter()
.any(|&(start, end)| pos >= start && pos < end)
};
let mut result = String::with_capacity(doc.len());
let mut offset = 0;
for line in doc.lines() {
let trimmed = line.trim();
let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
if is_boundary && !in_code(offset) {
offset += line.len() + 1; continue;
}
result.push_str(line);
result.push('\n');
offset += line.len() + 1;
}
if !doc.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
let prefix = "<!-- agent:boundary:";
let suffix = " -->";
let content_region = &doc[comp.open_end..comp.close_start];
let code_ranges = component::find_code_ranges(doc);
let mut search_from = 0;
while let Some(start) = content_region[search_from..].find(prefix) {
let abs_start = comp.open_end + search_from + start;
if code_ranges
.iter()
.any(|&(cs, ce)| abs_start >= cs && abs_start < ce)
{
search_from += start + prefix.len();
continue;
}
let after_prefix = &content_region[search_from + start + prefix.len()..];
if let Some(end) = after_prefix.find(suffix) {
return Some(after_prefix[..end].trim().to_string());
}
break;
}
None
}
fn default_mode(name: &str) -> &'static str {
match name {
"exchange" | "findings" => "append",
_ => "replace",
}
}
fn limit_lines(content: &str, max_lines: usize) -> String {
let lines: Vec<&str> = content.lines().collect();
if lines.len() <= max_lines {
return content.to_string();
}
lines[lines.len() - max_lines..].join("\n")
}
fn dedup_exchange_adjacent_lines(doc: &str) -> String {
let Ok(components) = component::parse(doc) else {
return doc.to_string();
};
let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
return doc.to_string();
};
let content = exchange.content(doc);
let mut deduped = String::with_capacity(content.len());
let mut prev_nonempty: Option<&str> = None;
for line in content.lines() {
let trimmed = line.trim();
let is_fence = is_code_fence_delimiter(trimmed);
if !trimmed.is_empty() && !is_fence && prev_nonempty == Some(line) {
continue;
}
deduped.push_str(line);
deduped.push('\n');
if !trimmed.is_empty() {
prev_nonempty = Some(line);
}
}
if !content.ends_with('\n') && deduped.ends_with('\n') {
deduped.pop();
}
if deduped == content {
return doc.to_string();
}
exchange.replace_content(doc, &deduped)
}
fn is_code_fence_delimiter(trimmed: &str) -> bool {
let fc = match trimmed.chars().next() {
Some(c) if c == '`' || c == '~' => c,
_ => return false,
};
let fence_len = trimmed.chars().take_while(|&c| c == fc).count();
fence_len >= 3
}
fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
match mode {
"append" => {
let stripped = strip_leading_overlap(existing, new_content);
format!("{}{}", existing, stripped)
}
"prepend" => format!("{}{}", new_content, existing),
_ => new_content.to_string(), }
}
fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
let Some(last) = last_nonempty else {
return new_content;
};
if is_code_fence_delimiter(last.trim()) {
return new_content;
}
let test = format!("{}\n", last);
if new_content.starts_with(test.as_str()) {
&new_content[test.len()..]
} else {
new_content
}
}
fn find_outside_code(
needle: &str,
haystack: &str,
from: usize,
code_ranges: &[(usize, usize)],
) -> Option<usize> {
let mut search_start = from;
loop {
let rel = haystack[search_start..].find(needle)?;
let abs = search_start + rel;
if code_ranges
.iter()
.any(|&(start, end)| abs >= start && abs < end)
{
search_start = abs + needle.len();
continue;
}
return Some(abs);
}
}
#[cfg(test)]
mod th {
use super::*;
use std::path::Path;
use tempfile::TempDir;
pub(crate) fn setup_project() -> TempDir {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
dir
}
#[allow(dead_code)]
pub(crate) fn apply_patches_via_path(
doc: &str,
patches: &[PatchBlock],
unmatched: &str,
file: &Path,
) -> Result<String> {
let summary = file.file_stem().and_then(|s| s.to_str());
let empty_str = std::collections::HashMap::new();
let empty_usize = std::collections::HashMap::new();
apply_patches_pure(doc, patches, unmatched, summary, &empty_str, &empty_usize)
}
#[allow(dead_code)]
pub(crate) fn apply_patches_with_overrides_via_path(
doc: &str,
patches: &[PatchBlock],
unmatched: &str,
file: &Path,
mode_overrides: &std::collections::HashMap<String, String>,
) -> Result<String> {
let summary = file.file_stem().and_then(|s| s.to_str());
let empty_str = std::collections::HashMap::new();
let empty_usize = std::collections::HashMap::new();
apply_patches_with_overrides_pure(
doc,
patches,
unmatched,
summary,
&empty_str,
&empty_usize,
mode_overrides,
)
}
pub(crate) fn exchange_component(doc: &str) -> Component {
component::parse(doc)
.unwrap()
.into_iter()
.find(|c| c.name == "exchange")
.unwrap()
}
}
#[cfg(test)]
pub(crate) use th::{
apply_patches_via_path, apply_patches_with_overrides_via_path, exchange_component,
setup_project,
};
#[cfg(test)]
mod tests {
#![allow(unused_imports)]
use super::*;
use std::path::Path;
use tempfile::TempDir;
#[test]
fn parse_single_patch() {
let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "status");
assert_eq!(patches[0].content, "Build passing.\n");
assert!(unmatched.is_empty());
}
#[test]
fn parse_multiple_patches() {
let response = "\
<!-- patch:status -->
All green.
<!-- /patch:status -->
<!-- patch:log -->
- New entry
<!-- /patch:log -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 2);
assert_eq!(patches[0].name, "status");
assert_eq!(patches[0].content, "All green.\n");
assert_eq!(patches[1].name, "log");
assert_eq!(patches[1].content, "- New entry\n");
assert!(unmatched.is_empty());
}
#[test]
fn parse_with_unmatched_content() {
let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "status");
assert!(unmatched.contains("Some free text."));
assert!(unmatched.contains("Trailing text."));
}
#[test]
fn parse_empty_response() {
let (patches, unmatched) = parse_patches("").unwrap();
assert!(patches.is_empty());
assert!(unmatched.is_empty());
}
#[test]
fn parse_no_patches() {
let response = "Just a plain response with no patch blocks.";
let (patches, unmatched) = parse_patches(response).unwrap();
assert!(patches.is_empty());
assert_eq!(unmatched, "Just a plain response with no patch blocks.");
}
fn npe1_doc(prompt: &str) -> String {
format!("# Session\n\n<!-- agent:exchange -->\n{prompt}\n<!-- /agent:exchange -->\n")
}
fn npe1_round_trip(response_body: &str, prompt: &str) -> String {
let dir = setup_project();
let doc_path = dir.path().join("plan.md");
let doc = npe1_doc(prompt);
std::fs::write(&doc_path, &doc).unwrap();
let response =
format!("<!-- patch:exchange -->\n{response_body}\n<!-- /patch:exchange -->\n");
let (patches, unmatched) = parse_patches(&response).unwrap();
let applied = apply_patches_via_path(&doc, &patches, &unmatched, &doc_path).unwrap();
reposition_boundary_to_end_clean_with_summary(&applied, Some("plan"))
}
#[test]
fn npe1_trailing_code_fence_survives_round_trip() {
let prompt = "❯ show me the snippet";
let response =
"### Re: show me the snippet\n\nHere is the code:\n\n```rust\nfn main() {}\n```";
let result = npe1_round_trip(response, prompt);
let fences = result.matches("```").count();
assert_eq!(
fences, 2,
"expected opening + closing fence to survive; got {fences} fences in:\n{result}"
);
assert!(result.contains("fn main() {}"), "code body lost:\n{result}");
assert!(
result.contains("```rust\nfn main() {}\n```"),
"fenced block mangled:\n{result}"
);
}
#[test]
fn npe1_second_cycle_preserves_prior_fence() {
let doc1 = npe1_round_trip("### Re: q\n\n```rust\nfn main() {}\n```", "❯ q");
assert_eq!(doc1.matches("```").count(), 2, "cycle1:\n{doc1}");
let dir = setup_project();
let doc_path = dir.path().join("plan.md");
let doc2_input = doc1.replace(
"<!-- /agent:exchange -->",
"❯ second q\n<!-- /agent:exchange -->",
);
std::fs::write(&doc_path, &doc2_input).unwrap();
let response =
"<!-- patch:exchange -->\n### Re: second q\n\nplain text\n<!-- /patch:exchange -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
let applied = apply_patches_via_path(&doc2_input, &patches, &unmatched, &doc_path).unwrap();
let result = reposition_boundary_to_end_clean_with_summary(&applied, Some("plan"));
assert_eq!(
result.matches("```").count(),
2,
"prior fence lost in cycle2:\n{result}"
);
assert!(
result.contains("```rust\nfn main() {}\n```"),
"cycle2:\n{result}"
);
}
#[test]
fn npe1_adjacent_fence_cases_survive_round_trip() {
let cases = [
("only-fence", "### Re: q\n```\nplain\n```"),
(
"fence+trailing-nl",
"### Re: q\n\n```rust\nfn main() {}\n```\n",
),
(
"indented-fence",
"### Re: q\n\n- item:\n ```rust\n fn main() {}\n ```",
),
(
"multi-fence",
"### Re: q\n\n```a\none\n```\n\nthen\n\n```b\ntwo\n```",
),
("no-heading-fence", "Here:\n\n```rust\nfn main() {}\n```"),
];
for (name, response) in cases {
let result = npe1_round_trip(response, "❯ q");
let expected = response.matches("```").count();
let got = result.matches("```").count();
assert_eq!(got, expected, "case {name}: fence count changed:\n{result}");
}
}
#[test]
fn apply_patches_replace() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("\nold\n"));
assert!(result.contains("<!-- agent:status -->"));
}
#[test]
fn apply_patches_unmatched_creates_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let result = apply_patches_via_path(doc, &[], "Extra info here", &doc_path).unwrap();
assert!(result.contains("<!-- agent:exchange -->"));
assert!(result.contains("Extra info here"));
assert!(result.contains("<!-- /agent:exchange -->"));
}
#[test]
fn apply_patches_unmatched_appends_to_existing_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let result = apply_patches_via_path(doc, &[], "new stuff", &doc_path).unwrap();
assert!(result.contains("previous"));
assert!(result.contains("new stuff"));
assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
}
#[test]
fn apply_patches_missing_component_routes_to_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "nonexistent".to_string(),
content: "overflow data\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(
result.contains("overflow data"),
"missing patch content should appear in exchange"
);
assert!(
result.contains("previous"),
"existing exchange content should be preserved"
);
}
#[test]
fn apply_patches_missing_component_creates_exchange() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "nonexistent".to_string(),
content: "overflow data\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(
result.contains("<!-- agent:exchange -->"),
"should create exchange component"
);
assert!(
result.contains("overflow data"),
"overflow content should be in exchange"
);
}
#[test]
fn is_template_mode_detection() {
assert!(is_template_mode(Some("template")));
assert!(!is_template_mode(Some("append")));
assert!(!is_template_mode(None));
}
#[test]
fn parse_patches_ignores_markers_in_fenced_code_block() {
let response = "\
<!-- patch:exchange -->
Here is how you use component markers:
```markdown
<!-- agent:exchange -->
example content
<!-- /agent:exchange -->
```
<!-- /patch:exchange -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "exchange");
assert!(patches[0].content.contains("```markdown"));
assert!(patches[0].content.contains("<!-- agent:exchange -->"));
assert!(unmatched.is_empty());
}
#[test]
fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
let response = "\
<!-- patch:exchange -->
Real content here.
```markdown
<!-- patch:fake -->
This is just an example.
<!-- /patch:fake -->
```
<!-- /patch:exchange -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1, "should only find the outer real patch");
assert_eq!(patches[0].name, "exchange");
assert!(
patches[0].content.contains("<!-- patch:fake -->"),
"code block content should be preserved"
);
assert!(unmatched.is_empty());
}
#[test]
fn parse_patches_ignores_markers_in_tilde_fence() {
let response = "\
<!-- patch:status -->
OK
<!-- /patch:status -->
~~~
<!-- patch:fake -->
example
<!-- /patch:fake -->
~~~
";
let (patches, _unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "status");
}
#[test]
fn parse_patches_ignores_closing_marker_in_code_block() {
let response = "\
<!-- patch:exchange -->
Example:
```
<!-- /patch:exchange -->
```
Real content continues.
<!-- /patch:exchange -->
";
let (patches, _unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "exchange");
assert!(patches[0].content.contains("Real content continues."));
}
#[test]
fn parse_patches_normal_markers_still_work() {
let response = "\
<!-- patch:status -->
All systems go.
<!-- /patch:status -->
<!-- patch:log -->
- Entry 1
<!-- /patch:log -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 2);
assert_eq!(patches[0].name, "status");
assert_eq!(patches[0].content, "All systems go.\n");
assert_eq!(patches[1].name, "log");
assert_eq!(patches[1].content, "- Entry 1\n");
assert!(unmatched.is_empty());
}
#[test]
fn parse_patches_accepts_replace_icebox() {
let response = "\
<!-- replace:icebox -->
- [ ] [#park1] Parked follow-up
<!-- /replace:icebox -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "icebox");
assert_eq!(patches[0].content, "- [ ] [#park1] Parked follow-up\n");
assert!(unmatched.is_empty());
}
#[test]
fn parse_patches_orphaned_opener_does_not_leak_into_unmatched() {
let response = "\
Some real content here.
<!-- patch:exchange -->
This opener has no matching close.
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert!(
patches.is_empty(),
"orphaned opener should not produce a patch"
);
assert_eq!(
unmatched, "Some real content here.\nThis opener has no matching close.",
"unmatched should contain text before and after the orphaned marker, but not the marker itself"
);
}
#[test]
fn parse_patches_orphaned_opener_between_valid_patches() {
let response = "\
<!-- patch:status -->
All good.
<!-- /patch:status -->
Interstitial text.
<!-- patch:exchange -->
<!-- patch:log -->
- Log entry
<!-- /patch:log -->
";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 2);
assert_eq!(patches[0].name, "status");
assert_eq!(patches[1].name, "log");
assert_eq!(unmatched, "Interstitial text.");
}
#[test]
fn inline_attr_mode_overrides_config() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/config.toml"),
"[components.status]\npatch = \"append\"\n",
)
.unwrap();
let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn inline_attr_mode_overrides_default() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn no_inline_attr_no_config_falls_back_to_default() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("old\n"));
assert!(result.contains("new\n"));
}
#[test]
fn inline_patch_attr_overrides_config() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/config.toml"),
"[components.status]\npatch = \"append\"\n",
)
.unwrap();
let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "new\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn inline_patch_attr_overrides_mode_attr() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc =
"<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn stream_override_beats_inline_attr() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "new\n".to_string(),
attrs: Default::default(),
}];
let mut overrides = std::collections::HashMap::new();
overrides.insert("exchange".to_string(), "replace".to_string());
let result =
apply_patches_with_overrides_via_path(doc, &patches, "", &doc_path, &overrides)
.unwrap();
assert!(result.contains("new\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn exchange_replace_override_replaces_unmatched_content() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange patch=append -->\nold\n<!-- agent:boundary:abc123 -->\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let mut overrides = std::collections::HashMap::new();
overrides.insert("exchange".to_string(), "replace".to_string());
let result = apply_patches_with_overrides_via_path(
doc,
&[],
"Compacted summary.\n",
&doc_path,
&overrides,
)
.unwrap();
assert!(result.contains("Compacted summary.\n"));
assert!(!result.contains("old\n"));
}
#[test]
fn exchange_replace_override_keeps_explicit_exchange_patch_authoritative() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:exchange patch=append -->\nold\n<!-- agent:boundary:abc123 -->\n<!-- /agent:exchange -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "Compacted summary.\n".to_string(),
attrs: Default::default(),
}];
let mut overrides = std::collections::HashMap::new();
overrides.insert("exchange".to_string(), "replace".to_string());
let result = apply_patches_with_overrides_via_path(
doc,
&patches,
"trailing note",
&doc_path,
&overrides,
)
.unwrap();
assert!(result.contains("Compacted summary.\n"));
assert!(result.contains("trailing note"));
assert!(!result.contains("old\n"));
}
#[test]
fn apply_patches_ignores_component_tags_in_code_blocks() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
# Scaffold Guide
Here is an example of a component:
```markdown
<!-- agent:status -->
example scaffold content
<!-- /agent:status -->
```
<!-- agent:status -->
real status content
<!-- /agent:status -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "status".to_string(),
content: "patched status\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(
result.contains("patched status\n"),
"real component should receive the patch"
);
assert!(
result.contains("example scaffold content"),
"code block content should be preserved"
);
assert!(
result.contains("```markdown\n<!-- agent:status -->"),
"code block markers should be preserved"
);
}
#[test]
fn unmatched_content_uses_boundary_marker() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = concat!(
"---\nagent_doc_format: template\n---\n",
"<!-- agent:exchange patch=append -->\n",
"User prompt here.\n",
"<!-- agent:boundary:test-uuid-123 -->\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&file, doc).unwrap();
let patches = vec![];
let unmatched = "### Re: Response\n\nResponse content here.\n";
let result = apply_patches_via_path(doc, &patches, unmatched, &file).unwrap();
let prompt_pos = result.find("User prompt here.").unwrap();
let response_pos = result.find("### Re: Response").unwrap();
assert!(
response_pos > prompt_pos,
"response should appear after the user prompt (boundary insertion)"
);
assert!(
!result.contains("test-uuid-123"),
"boundary marker should be consumed after insertion"
);
}
#[test]
fn explicit_patch_uses_boundary_marker() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = concat!(
"---\nagent_doc_format: template\n---\n",
"<!-- agent:exchange patch=append -->\n",
"User prompt here.\n",
"<!-- agent:boundary:patch-uuid-456 -->\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&file, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "### Re: Response\n\nResponse content.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &file).unwrap();
let prompt_pos = result.find("User prompt here.").unwrap();
let response_pos = result.find("### Re: Response").unwrap();
assert!(
response_pos > prompt_pos,
"response should appear after user prompt"
);
assert!(
!result.contains("patch-uuid-456"),
"boundary marker should be consumed by explicit patch"
);
}
#[test]
fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc =
"<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
std::fs::write(&file, doc).unwrap();
let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
assert!(
result.contains("<!-- agent:boundary:"),
"boundary must be re-inserted even when original doc had no boundary: {result}"
);
}
#[test]
fn boundary_survives_multiple_cycles() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
std::fs::write(&file, doc).unwrap();
let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
let (patches1, unmatched1) = parse_patches(response1).unwrap();
let result1 = apply_patches_via_path(doc, &patches1, &unmatched1, &file).unwrap();
assert!(
result1.contains("<!-- agent:boundary:"),
"cycle 1 must have boundary"
);
let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
let (patches2, unmatched2) = parse_patches(response2).unwrap();
let result2 = apply_patches_via_path(&result1, &patches2, &unmatched2, &file).unwrap();
assert!(
result2.contains("<!-- agent:boundary:"),
"cycle 2 must have boundary"
);
}
#[test]
fn exchange_boundary_insert_adds_blank_line_before_response_heading() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: Prior -- gpt-5\n\n",
"This guards against a future regression.\n",
"<!-- agent:boundary:abc123 -->\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&file, doc).unwrap();
let response = "<!-- patch:exchange -->\n### Re: Follow-up -- gpt-5\n\nDone.\n<!-- /patch:exchange -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
assert!(
result.contains("future regression.\n\n### Re: Follow-up"),
"response heading should be separated from the previous paragraph: {result}"
);
assert!(
!result.contains("future regression.\n### Re: Follow-up"),
"response heading must not attach to the previous paragraph: {result}"
);
}
#[test]
fn exchange_fallback_append_adds_blank_line_before_response_heading() {
let dir = setup_project();
let file = dir.path().join("test.md");
let doc = concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: Prior -- gpt-5\n\n",
"This guards against a future regression.\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&file, doc).unwrap();
let response = "<!-- patch:exchange -->\n### Re: Follow-up -- gpt-5\n\nDone.\n<!-- /patch:exchange -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
let result = apply_patches_via_path(doc, &patches, &unmatched, &file).unwrap();
assert!(
result.contains("future regression.\n\n### Re: Follow-up"),
"response heading should be separated from the previous paragraph: {result}"
);
assert!(
!result.contains("future regression.\n### Re: Follow-up"),
"response heading must not attach to the previous paragraph: {result}"
);
}
#[test]
fn remove_all_boundaries_skips_code_blocks() {
let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
let result = remove_all_boundaries(doc);
assert!(
result.contains("<!-- agent:boundary:fake-id -->"),
"boundary inside code block must be preserved: {result}"
);
assert!(
!result.contains("<!-- agent:boundary:real-id -->"),
"boundary outside code block must be removed: {result}"
);
}
#[test]
fn reposition_boundary_moves_to_end() {
let doc = "\
<!-- agent:exchange -->
Previous response.
<!-- agent:boundary:old-id -->
User prompt here.
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end(doc);
assert!(!result.contains("old-id"), "old boundary should be removed");
assert!(
result.contains("<!-- agent:boundary:"),
"new boundary should be inserted"
);
let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
let prompt_pos = result.find("User prompt here.").unwrap();
let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
assert!(
boundary_pos > prompt_pos,
"boundary should be after user prompt"
);
assert!(
boundary_pos < close_pos,
"boundary should be before close tag"
);
}
#[test]
fn reposition_boundary_no_exchange_unchanged() {
let doc = "\
<!-- agent:output -->
Some content.
<!-- /agent:output -->";
let result = reposition_boundary_to_end(doc);
assert!(
!result.contains("<!-- agent:boundary:"),
"no boundary should be added to non-exchange"
);
}
#[test]
fn reposition_boundary_clean_reuses_explicit_id() {
let doc = "\
<!-- agent:exchange -->
Previous response.
<!-- agent:boundary:old-id -->
User prompt here.
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end_clean_with_id(doc, Some("keep-this-id"));
assert!(!result.contains("old-id"), "old boundary should be removed");
assert!(
result.contains("<!-- agent:boundary:keep-this-id -->"),
"explicit boundary id should be reused"
);
assert_eq!(
result.matches("<!-- agent:boundary:").count(),
1,
"exactly one boundary should remain"
);
}
#[test]
fn reposition_appends_head_to_last_re_heading() {
let doc = "\
<!-- agent:exchange -->
### Re: older (HEAD)
old body
### Re: newer
new body
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end(doc);
assert!(
!result.contains("### Re: older (HEAD)"),
"stale (HEAD) on prior heading must be stripped; got:\n{result}"
);
assert!(
result.contains("### Re: older\n"),
"older heading must remain (without HEAD); got:\n{result}"
);
assert!(
result.contains("### Re: newer (HEAD)"),
"latest heading must get (HEAD); got:\n{result}"
);
assert_eq!(
result.matches("(HEAD)").count(),
1,
"exactly one (HEAD) in result; got:\n{result}"
);
}
#[test]
fn reposition_head_annotation_no_re_heading_unchanged() {
let doc = "\
<!-- agent:exchange -->
User text with no response headings.
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end(doc);
assert!(
!result.contains("(HEAD)"),
"no heading → no (HEAD); got:\n{result}"
);
}
#[test]
fn reposition_head_annotation_skips_code_fence() {
let doc = "\
<!-- agent:exchange -->
### Re: real heading
```markdown
### Re: fake heading in code fence
```
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end(doc);
assert!(
result.contains("### Re: real heading (HEAD)"),
"real heading outside fence gets (HEAD); got:\n{result}"
);
assert!(
result.contains("### Re: fake heading in code fence\n"),
"fenced heading must be untouched; got:\n{result}"
);
assert_eq!(
result.matches("(HEAD)").count(),
1,
"exactly one (HEAD) — fenced heading ignored; got:\n{result}"
);
}
#[test]
fn reposition_with_baseline_marks_all_new_re_headings() {
let doc = "\
<!-- agent:exchange -->
### Re: old-1
body a
### Re: old-2 (HEAD)
body b
### Re: new-1
body c
### Re: new-2
body d
<!-- /agent:exchange -->";
let mut baseline = std::collections::HashSet::new();
baseline.insert("### Re: old-1".to_string());
baseline.insert("### Re: old-2".to_string());
let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
assert!(
result.contains("### Re: old-1\n"),
"old-1 must not have (HEAD); got:\n{result}"
);
assert!(
result.contains("### Re: old-2\n"),
"old-2 must not have (HEAD); got:\n{result}"
);
assert!(
result.contains("### Re: new-1 (HEAD)"),
"new-1 must get (HEAD); got:\n{result}"
);
assert!(
result.contains("### Re: new-2 (HEAD)"),
"new-2 must get (HEAD); got:\n{result}"
);
assert_eq!(
result.matches("(HEAD)").count(),
2,
"exactly two (HEAD) markers; got:\n{result}"
);
}
#[test]
fn reposition_with_empty_baseline_marks_every_re_heading() {
let doc = "\
<!-- agent:exchange -->
### Re: first
a
### Re: second
b
<!-- /agent:exchange -->";
let baseline: std::collections::HashSet<String> = std::collections::HashSet::new();
let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
assert!(
result.contains("### Re: first (HEAD)"),
"first gets (HEAD); got:\n{result}"
);
assert!(
result.contains("### Re: second (HEAD)"),
"second gets (HEAD); got:\n{result}"
);
assert_eq!(
result.matches("(HEAD)").count(),
2,
"exactly two (HEAD) markers; got:\n{result}"
);
}
#[test]
fn exchange_baseline_headings_extracts_stripped_re_lines() {
let doc = "\
<!-- agent:exchange -->
### Re: one (HEAD)
body
### Re: two
more body
### Not a Re heading
body
<!-- /agent:exchange -->";
let set = exchange_baseline_headings(doc);
assert!(
set.contains("### Re: one"),
"stripped one present; got: {set:?}"
);
assert!(set.contains("### Re: two"), "two present; got: {set:?}");
assert_eq!(set.len(), 2, "only Re: headings; got: {set:?}");
}
#[test]
fn exchange_baseline_headings_normalizes_leading_whitespace() {
let doc = "\
<!-- agent:exchange -->
### Re: indented
body
### Re: flush
more
<!-- /agent:exchange -->";
let set = exchange_baseline_headings(doc);
assert!(
set.contains("### Re: indented"),
"indented entry normalized; got: {set:?}"
);
assert!(
set.contains("### Re: flush"),
"flush entry present; got: {set:?}"
);
}
#[test]
fn reposition_with_baseline_matches_indented_heading() {
let doc = "\
<!-- agent:exchange -->
### Re: foo
body
### Re: bar (HEAD)
body2
<!-- /agent:exchange -->";
let mut baseline = std::collections::HashSet::new();
baseline.insert("### Re: foo".to_string());
baseline.insert("### Re: bar".to_string());
let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
assert!(
result.contains(" ### Re: foo\n"),
"indented heading must remain unmarked; got:\n{result}"
);
assert!(
result.contains("### Re: bar (HEAD)"),
"last heading gets fallback (HEAD) marker; got:\n{result}"
);
assert_eq!(
result.matches("(HEAD)").count(),
1,
"exactly one (HEAD) via fallback; got:\n{result}"
);
}
#[test]
fn baseline_filter_empty_falls_back_to_last_heading() {
let doc = "\
<!-- agent:exchange -->
### Re: older
body
### Re: newer (HEAD)
more
<!-- /agent:exchange -->";
let mut baseline = std::collections::HashSet::new();
baseline.insert("### Re: older".to_string());
baseline.insert("### Re: newer".to_string());
let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
assert!(
result.contains("### Re: newer (HEAD)"),
"last heading retains (HEAD) via fallback; got:\n{result}"
);
assert!(
result.contains("### Re: older\n"),
"older heading remains unmarked; got:\n{result}"
);
assert_eq!(
result.matches("(HEAD)").count(),
1,
"exactly one (HEAD) marker after fallback; got:\n{result}"
);
}
#[test]
fn reposition_head_annotation_strips_multiple_stale() {
let doc = "\
<!-- agent:exchange -->
### Re: one (HEAD)
a
### Re: two (HEAD)
b
### Re: three
c
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end(doc);
assert_eq!(
result.matches("(HEAD)").count(),
1,
"exactly one (HEAD) after reposition; got:\n{result}"
);
assert!(result.contains("### Re: three (HEAD)"));
assert!(result.contains("### Re: one\n"));
assert!(result.contains("### Re: two\n"));
}
#[test]
fn preserve_head_keeps_head_markers_on_reposition() {
let doc = "\
<!-- agent:exchange -->
### Re: older
body a
### Re: newer (HEAD)
body b
<!-- agent:boundary:old-id -->
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end_preserve_head(doc);
assert!(
result.contains("### Re: newer (HEAD)"),
"preserve_head must keep (HEAD) on newest heading; got:\n{result}"
);
assert!(
!result.contains("old-id"),
"old boundary should be removed; got:\n{result}"
);
assert_eq!(
result.matches("<!-- agent:boundary:").count(),
1,
"exactly one fresh boundary; got:\n{result}"
);
}
#[test]
fn preserve_head_with_id_keeps_head_and_uses_explicit_id() {
let doc = "\
<!-- agent:exchange -->
### Re: topic (HEAD)
body
<!-- agent:boundary:old-id -->
<!-- /agent:exchange -->";
let result = reposition_boundary_to_end_preserve_head_with_id(doc, Some("explicit-id"));
assert!(
result.contains("### Re: topic (HEAD)"),
"preserve_head must keep (HEAD); got:\n{result}"
);
assert!(
result.contains("<!-- agent:boundary:explicit-id -->"),
"explicit boundary id should be used; got:\n{result}"
);
assert!(
!result.contains("old-id"),
"old boundary gone; got:\n{result}"
);
}
#[test]
fn clean_strips_head_but_preserve_head_keeps_it() {
let doc = "\
<!-- agent:exchange -->
### Re: first
body a
### Re: second (HEAD)
body b
<!-- /agent:exchange -->";
let clean = reposition_boundary_to_end_clean(doc);
let preserved = reposition_boundary_to_end_preserve_head(doc);
assert!(
!clean.contains("(HEAD)"),
"clean variant must strip (HEAD); got:\n{clean}"
);
assert!(
preserved.contains("### Re: second (HEAD)"),
"preserve_head variant must keep (HEAD); got:\n{preserved}"
);
}
#[test]
fn max_lines_inline_attr_trims_content() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "log".to_string(),
content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(!result.contains("line1"));
assert!(!result.contains("line2"));
assert!(result.contains("line3"));
assert!(result.contains("line4"));
assert!(result.contains("line5"));
}
#[test]
fn max_lines_noop_when_under_limit() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "log".to_string(),
content: "line1\nline2\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("line1"));
assert!(result.contains("line2"));
}
#[test]
fn max_lines_inline_beats_toml() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
std::fs::write(
dir.path().join(".agent-doc/config.toml"),
"[components.log]\nmax_lines = 1\n",
)
.unwrap();
let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "log".to_string(),
content: "a\nb\nc\nd\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(result.contains("b"));
assert!(result.contains("c"));
assert!(result.contains("d"));
}
#[test]
fn parse_patch_with_transfer_source_attr() {
let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
let (patches, unmatched) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "exchange");
assert_eq!(patches[0].content, "Transferred content.\n");
assert_eq!(
patches[0].attrs.get("transfer-source"),
Some(&"\"tasks/eval-runner.md\"".to_string())
);
assert!(unmatched.is_empty());
}
#[test]
fn parse_patch_without_attrs() {
let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
let (patches, _) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert!(patches[0].attrs.is_empty());
}
#[test]
fn parse_patch_with_multiple_attrs() {
let response =
"<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
let (patches, _) = parse_patches(response).unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].name, "output");
assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
}
#[test]
fn apply_patches_dedup_exchange_adjacent_echo() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
<!-- agent:exchange patch=append -->
❯ How do I configure .mise.toml?
<!-- /agent:exchange -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
let count = result.matches("❯ How do I configure .mise.toml?").count();
assert_eq!(
count, 1,
"prompt line should appear exactly once, got:\n{result}"
);
assert!(
result.contains("### Re: configure .mise.toml"),
"response heading should be present"
);
assert!(
result.contains("Use `[env]` section."),
"response body should be present"
);
}
#[test]
fn apply_patches_dedup_preserves_blank_lines() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
<!-- agent:exchange patch=append -->
Previous response.
<!-- /agent:exchange -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert!(
result.contains("Previous response."),
"existing content preserved"
);
assert!(
result.contains("### Re: something"),
"response heading present"
);
assert!(result.contains('\n'), "blank lines preserved");
}
#[test]
fn apply_patches_dedup_preserves_adjacent_code_fences() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
<!-- agent:exchange patch=append -->
Some text.
```
code block 1
```
```
code block 2
```
<!-- /agent:exchange -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "### Re: test — opus-4-6\n\nResponse.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
let fence_count = result.matches("```").count();
assert_eq!(
fence_count, 4,
"all four code fences must survive dedup, got:\n{result}"
);
assert!(
result.contains("```\n```"),
"adjacent code fences must be preserved"
);
}
#[test]
fn apply_patches_append_preserves_response_leading_code_fence_after_prompt_fence() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
<!-- agent:exchange patch=append -->
❯ show fenced prompt
```
prompt body
```
<!-- /agent:exchange -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "```\nresponse body\n```\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
assert_eq!(
result.matches("```").count(),
4,
"prompt and response fences must all survive append:\n{result}"
);
assert!(
result.contains("```\n```\nresponse body\n```"),
"response opening fence must remain after the prompt closing fence:\n{result}"
);
}
#[test]
fn apply_patches_marks_new_exchange_headings_with_head() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = "\
<!-- agent:exchange patch=append -->
### Re: earlier — gpt-5
Existing answer.
<!-- /agent:exchange -->
";
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "### Re: latest — gpt-5\n\nFresh answer.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_with_overrides_via_path(
doc,
&patches,
"",
&doc_path,
&Default::default(),
)
.unwrap();
assert!(
result.contains("### Re: earlier — gpt-5\n"),
"existing response heading must stay clean; got:\n{result}"
);
assert!(
result.contains("### Re: latest — gpt-5 (HEAD)\n"),
"new response heading must surface as transient HEAD; got:\n{result}"
);
assert_eq!(
result.matches("(HEAD)").count(),
1,
"exactly one transient HEAD marker expected; got:\n{result}"
);
}
#[test]
fn apply_patches_binds_exchange_response_to_oldest_matching_unresolved_prompt() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: prior — gpt-5\n\n",
"Done.\n",
"<!-- agent:boundary:old-anchor -->\n",
"❯ do #wcup1. spec-test-commit-push\n\n",
"❯ do #wcx1. spec-test-commit-push\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "### Re: #wcup1 — gpt-5\n\nAlready complete.\n".to_string(),
attrs: Default::default(),
}];
let result = apply_patches_with_overrides_via_path(
doc,
&patches,
"",
&doc_path,
&Default::default(),
)
.unwrap();
let wcup1_prompt = result.find("❯ do #wcup1. spec-test-commit-push").unwrap();
let wcup1_response = result.find("### Re: #wcup1 — gpt-5 (HEAD)").unwrap();
let new_boundary = result.rfind("<!-- agent:boundary:").unwrap();
let wcx1_prompt = result.find("❯ do #wcx1. spec-test-commit-push").unwrap();
assert!(
wcup1_prompt < wcup1_response,
"response must land after the matched older prompt:\n{result}"
);
assert!(
wcup1_response < new_boundary,
"new boundary must move behind the response:\n{result}"
);
assert!(
new_boundary < wcx1_prompt,
"newer unresolved prompts must remain after the new boundary:\n{result}"
);
}
#[test]
fn apply_patches_rejects_exchange_response_that_skips_older_unresolved_prompt() {
let dir = setup_project();
let doc_path = dir.path().join("test.md");
let doc = concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: prior — gpt-5\n\n",
"Done.\n",
"<!-- agent:boundary:old-anchor -->\n",
"❯ do #wcup1. spec-test-commit-push\n\n",
"❯ do #wcx1. spec-test-commit-push\n",
"<!-- /agent:exchange -->\n",
);
std::fs::write(&doc_path, doc).unwrap();
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "### Re: #wcx1 — gpt-5\n\nNot done yet.\n".to_string(),
attrs: Default::default(),
}];
let err = apply_patches_with_overrides_via_path(
doc,
&patches,
"",
&doc_path,
&Default::default(),
)
.expect_err("later-matching response should fail closed");
assert!(
err.to_string().contains("skip older unresolved prompt"),
"unexpected error: {err}"
);
}
#[test]
fn append_anchor_falls_back_when_single_prompt_edited_after_baseline() {
let original = concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: prior — gpt-5\n\nDone.\n",
"<!-- agent:boundary:b1 -->\n",
"❯ Why is it still running?\n",
"<!-- /agent:exchange -->\n",
);
let current = concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: prior — gpt-5\n\nDone.\n",
"Why its still running?\n",
"<!-- agent:boundary:b2 -->\n",
"<!-- /agent:exchange -->\n",
);
let comp = exchange_component(current);
let patch = "### Re: still running — gpt-5\n\nBy design.\n";
let result = append_exchange_patch_after_prompt_anchor(original, current, &comp, patch)
.unwrap()
.expect("single edited prompt must anchor via the resilient fallback");
let prompt_pos = result.find("Why its still running?").unwrap();
let resp_pos = result.find("### Re: still running").unwrap();
let boundary_pos = result.rfind("<!-- agent:boundary:").unwrap();
assert!(
prompt_pos < resp_pos,
"response must land after the edited prompt:\n{result}"
);
assert!(
resp_pos < boundary_pos,
"new boundary must move behind the response:\n{result}"
);
}
#[test]
fn append_anchor_fails_closed_when_multiple_prompts_drift_from_baseline() {
let original = concat!(
"<!-- agent:exchange patch=append -->\n",
"<!-- agent:boundary:b1 -->\n",
"❯ First original question?\n\n",
"❯ Second original question?\n",
"<!-- /agent:exchange -->\n",
);
let current = concat!(
"<!-- agent:exchange patch=append -->\n",
"❯ First EDITED question?\n\n",
"❯ Second EDITED question?\n",
"<!-- agent:boundary:b2 -->\n",
"<!-- /agent:exchange -->\n",
);
let comp = exchange_component(current);
let patch = "### Re: answer — gpt-5\n\nBody.\n";
let err = append_exchange_patch_after_prompt_anchor(original, current, &comp, patch)
.expect_err("ambiguous multi-prompt baseline drift must fail closed");
assert!(
err.to_string().contains("drifted from the baseline")
&& err.to_string().contains("refresh the baseline"),
"unexpected error: {err}"
);
}
#[test]
fn apply_mode_append_strips_leading_overlap() {
let existing = "❯ How do I configure .mise.toml?\n";
let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
let result = apply_mode("append", existing, new_content);
let count = result.matches("❯ How do I configure .mise.toml?").count();
assert_eq!(count, 1, "overlap line should appear exactly once");
assert!(result.contains("### Re: configure"));
}
#[test]
fn apply_mode_append_preserves_leading_code_fence_overlap() {
let existing = "❯ show fenced prompt\n```\nprompt body\n```\n";
let new_content = "```\nresponse body\n```\n";
let result = apply_mode("append", existing, new_content);
assert_eq!(
result.matches("```").count(),
4,
"code fence delimiters are structural, not duplicate prompt overlap:\n{result}"
);
assert!(
result.contains("```\n```\nresponse body\n```"),
"adjacent prompt/response fences must remain distinct:\n{result}"
);
}
#[test]
fn strip_trailing_caret_removes_bare_prompt_line() {
let content = "Answer text.\n❯\n";
assert_eq!(strip_trailing_caret_lines(content), "Answer text.\n");
}
#[test]
fn strip_trailing_caret_removes_multiple_trailing_lines() {
let content = "Answer.\n❯\n❯\n";
assert_eq!(strip_trailing_caret_lines(content), "Answer.\n");
}
#[test]
fn strip_trailing_caret_preserves_mid_content_caret() {
let content = "### Re: topic\n\n❯ user question echoed\n\nAnswer.\n";
assert_eq!(strip_trailing_caret_lines(content), content);
}
#[test]
fn strip_trailing_caret_preserves_caret_with_text() {
let content = "Answer.\n❯ follow-up\n";
assert_eq!(strip_trailing_caret_lines(content), content);
}
#[test]
fn strip_trailing_caret_handles_no_trailing_newline() {
let content = "Answer.\n❯";
assert_eq!(strip_trailing_caret_lines(content), "Answer.");
}
#[test]
fn strip_trailing_caret_noop_when_no_caret() {
let content = "Answer.\n";
assert_eq!(strip_trailing_caret_lines(content), content);
}
#[test]
fn apply_patches_strips_trailing_caret_from_exchange() {
let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n❯ prior question\n<!-- /agent:exchange -->\n";
let patches = vec![PatchBlock {
name: "exchange".to_string(),
content: "### Re: thing\n\nAnswer.\n❯\n".to_string(),
attrs: Default::default(),
}];
let doc_path = std::path::PathBuf::from("/tmp/test.md");
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
let components = component::parse(&result).unwrap();
let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
let content = exchange.content(&result);
let has_bare_caret_before_boundary = content
.lines()
.collect::<Vec<_>>()
.windows(2)
.any(|w| w[0].trim() == "❯" && w[1].starts_with("<!-- agent:boundary"));
assert!(
!has_bare_caret_before_boundary,
"bare ❯ line must not appear before boundary marker. content:\n{}",
content
);
}
#[test]
fn apply_patches_preserves_caret_in_non_exchange() {
let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n<!-- agent:notes patch=replace -->\n<!-- /agent:notes -->\n";
let patches = vec![PatchBlock {
name: "notes".to_string(),
content: "note body\n❯\n".to_string(),
attrs: Default::default(),
}];
let doc_path = std::path::PathBuf::from("/tmp/test.md");
let result = apply_patches_via_path(doc, &patches, "", &doc_path).unwrap();
let components = component::parse(&result).unwrap();
let notes = components.iter().find(|c| c.name == "notes").unwrap();
assert!(
notes.content(&result).contains("❯"),
"non-exchange content retains ❯"
);
}
#[test]
fn apply_mode_append_no_overlap_unchanged() {
let existing = "Previous content.\n";
let new_content = "### Re: something\n\nAnswer.\n";
let result = apply_mode("append", existing, new_content);
assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
}
#[test]
fn repair_queue_escape_removes_struck_items_below_marker_in_parking_lot() {
let doc = concat!(
"<!-- agent:queue go -->\n",
"- ~~:round_pushpin: do [#alpha]~~\n",
"- :round_pushpin: do [#beta]\n",
"<!-- /agent:queue -->\n",
"###\n",
"<!--\n",
"a real scratch note line\n",
"- ~~:round_pushpin: do [#gamma]~~\n",
"- ~~:pushpin: do [#delta]~~\n",
"-->\n",
"<!-- agent:backlog -->\n",
"- [ ] [#keep] real backlog item\n",
"<!-- /agent:backlog -->\n",
);
let repaired =
repair_queue_struck_items_escaped_below_marker(doc).expect("displaced items removed");
assert!(
!repaired.contains("[#gamma]"),
"gamma displaced struck removed"
);
assert!(
!repaired.contains("[#delta]"),
"delta displaced struck removed"
);
assert!(
repaired.contains("- ~~:round_pushpin: do [#alpha]~~"),
"in-queue struck kept"
);
assert!(
repaired.contains("- :round_pushpin: do [#beta]"),
"in-queue active kept"
);
assert!(
repaired.contains("a real scratch note line"),
"scratch text kept"
);
assert!(
repaired.contains("[#keep] real backlog item"),
"backlog content kept"
);
}
#[test]
fn repair_queue_escape_noop_when_no_displaced_items() {
let doc = concat!(
"<!-- agent:queue go -->\n",
"- ~~:round_pushpin: do [#alpha]~~\n",
"- :round_pushpin: do [#beta]\n",
"<!-- /agent:queue -->\n",
"###\n",
"<!--\n",
"- ~~an ordinary user strikethrough note~~\n",
"-->\n",
);
assert!(repair_queue_struck_items_escaped_below_marker(doc).is_none());
}
#[test]
fn repair_queue_escape_handles_bare_lines_after_marker() {
let doc = concat!(
"<!-- agent:queue go -->\n",
"- :round_pushpin: do [#beta]\n",
"<!-- /agent:queue -->\n",
"- ~~:round_pushpin: do [#gamma]~~\n",
"- ~~do [#delta]~~\n",
"<!-- agent:backlog -->\n",
"- [ ] [#keep] item\n",
"<!-- /agent:backlog -->\n",
);
let repaired =
repair_queue_struck_items_escaped_below_marker(doc).expect("bare displaced removed");
assert!(!repaired.contains("[#gamma]"));
assert!(!repaired.contains("[#delta]"));
assert!(repaired.contains("- :round_pushpin: do [#beta]"));
assert!(repaired.contains("[#keep] item"));
}
#[test]
fn normalize_editor_visible_template_structure_preserves_duplicate_prompt_html_comment_body() {
let prompt = "The duplicate content corrupting document and duplicate prompt issues happened yet again. Very tired of playing whack-a-mole. Reproduce bugs with tests first that fail and fix the implementation. #spec-test-build-install-commit-push";
let doc = format!(
concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: prior — gpt-5\n",
"Done.\n",
"<!-- agent:boundary:head -->\n",
"{prompt}\n",
"<!-- /agent:exchange -->\n\n",
"###\n\n",
"<!--\n",
"{prompt}\n",
"-->\n\n",
"<!--\n",
"Keep this unrelated scratch note hidden.\n",
"-->\n\n",
"<!-- agent:backlog -->\n",
"- [ ] keep me\n",
"<!-- /agent:backlog -->\n"
),
prompt = prompt
);
let repaired = normalize_editor_visible_template_structure(&doc)
.expect("editor-visible normalization should preserve scratch comments");
let duplicate_comment = format!("\n<!--\n{prompt}\n-->\n");
assert!(
repaired.contains(&duplicate_comment),
"editor-visible normalization has no baseline ownership proof and must preserve post-exchange scratch text:\n{repaired}"
);
assert!(
repaired.contains("<!-- agent:backlog -->\n- [ ] keep me"),
"tracked-work scaffold should remain intact:\n{repaired}"
);
assert!(
repaired.contains("Keep this unrelated scratch note hidden."),
"unrelated scratch comments must stay outside exchange:\n{repaired}"
);
}
#[test]
fn normalize_editor_visible_template_structure_preserves_mixed_prompt_comment_scratch_lines() {
let exchange_prompt = "The content of the html comment below this agent:exchange element was deleted after the last agent-doc turn. The duplicate corrupt document bug & the duplicated prompt happened yet again as I was typing in this prompt. Should we diff line by line? Do we still have race conditions?";
let duplicate_prompt_line = "The duplicate corrupt document bug & the duplicated prompt happened yet again as I was typing in this prompt. Should we diff line by line? Do we still have race conditions?";
let doc = format!(
concat!(
"<!-- agent:exchange patch=append -->\n",
"### Re: prior - gpt-5\n",
"Done.\n",
"<!-- agent:boundary:head -->\n",
"{exchange_prompt}\n",
"#spec-test-build-install-commit-push\n",
"<!-- /agent:exchange -->\n\n",
"###\n",
"<!--\n",
"{duplicate_prompt_line}\n",
"#spec-test-build-install-commit-push\n",
"---\n",
"Look through the Claude + Codex + agent-doc session logs for #next-steps to fix bugs.\n",
"-->\n\n",
"<!-- agent:backlog -->\n",
"<!-- /agent:backlog -->\n"
),
exchange_prompt = exchange_prompt,
duplicate_prompt_line = duplicate_prompt_line,
);
let repaired = normalize_editor_visible_template_structure(&doc)
.expect("editor-visible normalization should preserve mixed scratch comments");
assert!(
repaired.contains(&format!("<!--\n{duplicate_prompt_line}")),
"editor-visible normalization must not scrub mixed scratch comments without baseline proof:\n{repaired}"
);
assert!(
repaired.contains("Look through the Claude + Codex + agent-doc session logs"),
"unrelated scratch lines in the same ordinary comment must survive:\n{repaired}"
);
assert!(
repaired.contains(&format!(
"<!--\n{duplicate_prompt_line}\n#spec-test-build-install-commit-push\n---\nLook through"
)),
"the mixed comment shell and full body should be preserved:\n{repaired}"
);
}
#[test]
fn normalize_editor_visible_template_structure_preserves_scratch_comment_after_compact_summary()
{
let prompt = "The duplicate corrupt document bug & the duplicated prompt happened yet again as I was typing in this prompt. Should we diff line by line? Do we still have race conditions?";
let doc = format!(
concat!(
"<!-- agent:exchange patch=append -->\n",
"### Session Summary\n\n",
"Compacted content:\n",
"- Trailing prompt/context: {prompt}\n",
"❯ {prompt}\n",
"❯ #spec-test-build-install-commit-push\n",
"### Re: compact prompt duplication — gpt-5\n\n",
"Line-by-line diff was the right diagnostic.\n",
"<!-- agent:boundary:head -->\n",
"<!-- /agent:exchange -->\n",
"###\n",
"<!--\n",
"Look through the Claude + Codex + agent-doc session logs\n",
"-->\n\n",
"<!-- agent:backlog -->\n",
"<!-- /agent:backlog -->\n"
),
prompt = prompt
);
let repaired = normalize_editor_visible_template_structure(&doc)
.expect("editor-visible normalization should preserve unrelated scratch comments");
assert!(
repaired.contains("Look through the Claude + Codex + agent-doc session logs"),
"unrelated post-exchange scratch comment text must survive:\n{repaired}"
);
}
#[test]
fn normalize_editor_visible_template_structure_ignores_response_quoted_scratch_comment() {
let scratch =
"Look through the Claude + Codex + agent-doc session logs for #next-steps to fix bugs.";
let doc = format!(
concat!(
"<!-- agent:exchange patch=append -->\n",
"❯ Please inspect the latest route cleanup report. #spec-test-build-install-commit-push\n",
"### Re: route cleanup — gpt-5\n\n",
"{scratch}\n",
"<!-- agent:boundary:head -->\n",
"<!-- /agent:exchange -->\n",
"###\n",
"<!--\n",
"{scratch}\n",
"-->\n\n",
"<!-- agent:backlog -->\n",
"<!-- /agent:backlog -->\n"
),
scratch = scratch
);
let repaired = normalize_editor_visible_template_structure(&doc)
.expect("response-quoted scratch comments should not be prompt residue");
assert!(
repaired.contains(scratch),
"response text must not authorize deleting matching user-owned scratch comments:\n{repaired}"
);
assert!(
repaired.contains(&format!("<!--\n{scratch}\n-->")),
"ordinary scratch comment body must stay intact:\n{repaired}"
);
}
#[test]
fn normalize_removes_duplicate_answered_prompt_tail_after_boundary() {
let prompt = "The content of the html comment below this agent:exchange element was deleted after the last agent-doc turn. Should we diff line by line?";
let doc = format!(
concat!(
"<!-- agent:exchange patch=append -->\n",
"❯ {prompt}\n",
"❯ #spec-test-build-install-commit-push\n",
"### Re: mixed scratch comment deletion — gpt-5\n\n",
"Answered already.\n",
"<!-- agent:boundary:head -->\n",
"❯ {prompt}\n",
"❯ #spec-test-build-install-commit-push\n",
"<!-- /agent:exchange -->\n"
),
prompt = prompt
);
let repaired = normalize_editor_visible_template_structure(&doc)
.expect("editor-visible normalization should remove duplicate answered prompt tail");
assert!(
repaired.contains(&format!(
"❯ {prompt}\n❯ #spec-test-build-install-commit-push\n### Re:"
)),
"answered prompt block must remain in exchange history:\n{repaired}"
);
assert!(
!repaired.contains(&format!("<!-- agent:boundary:head -->\n❯ {prompt}")),
"duplicate answered-form prompt tail after the boundary should be removed:\n{repaired}"
);
assert!(
repaired.contains("<!-- agent:boundary:head -->\n<!-- /agent:exchange -->"),
"boundary should stay at the exchange end after cleanup:\n{repaired}"
);
}
#[test]
fn normalize_editor_visible_template_structure_rejects_plain_markdown_duplicate_residue() {
let prompt =
"Please keep this exact sentence around for duplicate residue coverage in markdown";
let doc = format!(
concat!(
"<!-- agent:exchange patch=append -->\n",
"❯ {prompt}\n",
"### Re: duplicate residue — gpt-5\n\n",
"Answered.\n",
"<!-- agent:boundary:head -->\n",
"<!-- /agent:exchange -->\n\n",
"###\n\n",
"{prompt}\n",
"<!-- agent:backlog -->\n",
"- [ ] keep me\n",
"<!-- /agent:backlog -->\n"
),
prompt = prompt
);
let err = normalize_editor_visible_template_structure(&doc).unwrap_err();
assert!(
err.to_string().contains("duplicate prompt residue"),
"editor-visible normalization must fail closed on duplicate prompt Markdown residue: {err}"
);
}
#[test]
fn normalize_editor_visible_template_structure_rejects_mixed_duplicate_scaffold() {
let doc = concat!(
"<!-- agent:exchange patch=append -->\n",
"### Session Summary\n",
"<!-- /agent:exchange -->\n",
"###\n",
"<!-- agent:queue -->\n",
"<!-- /agent:queue -->\n",
"user typed into duplicated shell\n",
"<!-- /agent:exchange -->\n",
"###\n",
"<!-- agent:queue -->\n",
"<!-- /agent:queue -->\n"
);
let err = normalize_editor_visible_template_structure(doc).unwrap_err();
assert!(
err.to_string().contains("mixed duplicate scaffold"),
"unexpected error: {err}"
);
}
#[test]
fn repair_duplicate_exchange_opener_merges_two_blocks() {
let doc = concat!(
"---\nagent_doc_format: template\n---\n\n",
"<!-- agent:exchange patch=append -->\n",
"❯ first question\n\n",
"### Re: first — opus-4-6\n\n",
"First answer.\n",
"<!-- /agent:exchange -->\n",
"<!-- agent:exchange patch=append -->\n",
"❯ second question\n\n",
"### Re: second — opus-4-6\n\n",
"Second answer.\n",
"<!-- /agent:exchange -->\n\n",
"<!-- agent:backlog -->\n",
"- [ ] keep me\n",
"<!-- /agent:backlog -->\n"
);
let repaired = repair_duplicate_exchange_opener(doc)
.unwrap()
.expect("duplicate opener repair should apply");
assert_eq!(
repaired.matches("<!-- agent:exchange").count(),
1,
"repair should leave exactly one exchange opener:\n{repaired}"
);
assert_eq!(
repaired.matches("<!-- /agent:exchange -->").count(),
1,
"repair should leave exactly one exchange closer:\n{repaired}"
);
assert!(
repaired.contains("First answer."),
"first block content should be preserved:\n{repaired}"
);
assert!(
repaired.contains("Second answer."),
"second block content should be preserved:\n{repaired}"
);
assert!(
repaired.contains("<!-- agent:backlog -->"),
"backlog should be preserved:\n{repaired}"
);
let first_pos = repaired.find("First answer.").unwrap();
let second_pos = repaired.find("Second answer.").unwrap();
assert!(
first_pos < second_pos,
"first content should appear before second:\n{repaired}"
);
}
#[test]
fn repair_duplicate_exchange_opener_returns_none_for_single() {
let doc = concat!(
"---\nagent_doc_format: template\n---\n\n",
"<!-- agent:exchange patch=append -->\n",
"❯ question\n",
"### Re: answer — opus-4-6\n\n",
"Answer.\n",
"<!-- /agent:exchange -->\n"
);
let result = repair_duplicate_exchange_opener(doc).unwrap();
assert!(result.is_none(), "single exchange block should return None");
}
#[test]
fn strip_conversation_tail_outside_exchange_removes_escaped_heading_tail_only() {
let doc = concat!(
"---\nagent_doc_format: template\n---\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: earlier — gpt-5\n",
"<!-- agent:boundary:abc123 -->\n",
"<!-- /agent:exchange -->\n\n",
"[//]: # (leave this note outside exchange)\n\n",
"## Assistant\n\n",
"Escaped answer.\n"
);
let stripped = strip_conversation_tail_outside_exchange(doc)
.unwrap()
.expect("escaped heading tail should be removable");
assert!(
stripped.contains("[//]: # (leave this note outside exchange)"),
"comment-only note should remain outside exchange:\n{stripped}"
);
assert!(
!stripped.contains("## Assistant"),
"escaped assistant tail should be removed:\n{stripped}"
);
}
#[test]
fn strip_conversation_tail_outside_exchange_removes_gap_before_backlog_only() {
let doc = concat!(
"---\nagent_doc_format: template\n---\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: earlier — gpt-5\n",
"<!-- agent:boundary:abc123 -->\n",
"<!-- /agent:exchange -->\n\n",
"###\n\n",
"### Re: gap — gpt-5\n\n",
"Escaped answer.\n\n",
"<!-- agent:backlog -->\n",
"- [ ] keep me\n",
"<!-- /agent:backlog -->\n"
);
let stripped = strip_conversation_tail_outside_exchange(doc)
.unwrap()
.expect("escaped gap should be removable");
assert!(
stripped.contains("\n###\n\n<!-- agent:backlog -->"),
"plain gap marker should stay outside exchange:\n{stripped}"
);
assert!(
!stripped.contains("### Re: gap — gpt-5"),
"escaped response should be removed from the gap:\n{stripped}"
);
}
#[test]
fn deleted_conversation_tail_cleanup_accepts_prompt_prelude_deletion() {
let before = concat!(
"---\nagent_doc_format: template\n---\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: earlier — gpt-5\n",
"<!-- agent:boundary:abc123 -->\n",
"<!-- /agent:exchange -->\n\n",
"I see this routed prompt sitting outside exchange.\n",
"It should have been entered by the managed pane.\n\n",
"do #oobtaildel. spec-test-build-install-commit-push\n\n",
"<!-- agent:backlog -->\n",
"- [ ] keep me\n",
"<!-- /agent:backlog -->\n"
);
let after = concat!(
"---\nagent_doc_format: template\n---\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: earlier — gpt-5\n",
"<!-- agent:boundary:abc123 -->\n",
"<!-- /agent:exchange -->\n\n",
"<!-- agent:backlog -->\n",
"- [ ] keep me\n",
"<!-- /agent:backlog -->\n"
);
let cleaned = deleted_conversation_tail_cleanup(before, after)
.unwrap()
.expect("prompt-prelude cleanup should be accepted");
assert_eq!(cleaned, after);
}
#[test]
fn deleted_conversation_tail_cleanup_rejects_plain_note_deletion() {
let before = concat!(
"---\nagent_doc_format: template\n---\n\n",
"<!-- agent:exchange patch=append -->\n",
"### Re: earlier — gpt-5\n",
"<!-- /agent:exchange -->\n\n",
"Design note: keep this outside exchange.\n\n",
"<!-- agent:backlog -->\n",
"- [ ] keep me\n",
"<!-- /agent:backlog -->\n"
);
let after = before.replace("Design note: keep this outside exchange.\n\n", "");
let cleaned = deleted_conversation_tail_cleanup(before, &after).unwrap();
assert!(
cleaned.is_none(),
"ordinary note deletion should not be treated as escaped conversation cleanup"
);
}
}