use anyhow::{Context, Result};
use std::path::Path;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::agent::streaming::{StreamChunk, StreamingAgent};
use crate::{agent, config::Config, crdt, diff, frontmatter, git, recover, snapshot, template};
pub fn run(
file: &Path,
interval_ms: u64,
agent_name: Option<&str>,
model: Option<&str>,
no_git: bool,
config: &Config,
) -> Result<()> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
let raw_content = std::fs::read_to_string(file)?;
let (fm, _body) = frontmatter::parse(&raw_content)?;
let resolved = fm.resolve_mode();
if !resolved.is_crdt() {
anyhow::bail!(
"document write mode is {:?}, expected crdt. Use `agent-doc mode {} --set stream` first.",
resolved.write,
file.display()
);
}
let stream_config = fm.stream_config.clone().unwrap_or_default();
let interval = stream_config.interval.unwrap_or(interval_ms);
let target = stream_config.target.as_deref().unwrap_or("exchange");
let thinking_enabled = stream_config.thinking.unwrap_or(false);
let thinking_target = stream_config.thinking_target.clone();
eprintln!(
"[stream] starting for {} (interval: {}ms, target: {}, thinking: {}{})",
file.display(),
interval,
target,
thinking_enabled,
thinking_target.as_ref().map(|t| format!(", thinking_target: {}", t)).unwrap_or_default()
);
let the_diff = match diff::compute(file)? {
Some(d) => {
eprintln!("[stream] diff computed ({} bytes)", d.len());
d
}
None => {
eprintln!("[stream] Nothing changed since last run for {}", file.display());
return Ok(());
}
};
let (content_original, _session_id) = frontmatter::ensure_session(&raw_content)?;
if content_original != raw_content {
std::fs::write(file, &content_original)?;
}
let (fm, _body) = frontmatter::parse(&content_original)?;
let agent_name = agent_name
.or(fm.agent.as_deref())
.or(config.default_agent.as_deref())
.unwrap_or("claude");
let agent_config = config.agents.get(agent_name);
let expanded_env = if fm.env.is_empty() {
Vec::new()
} else {
match crate::env::expand_values(&fm.env) {
Ok(e) => e,
Err(e) => {
eprintln!("[stream] env expansion failed: {} — continuing without env", e);
Vec::new()
}
}
};
let streaming_agent = resolve_streaming(agent_name, agent_config, expanded_env)?;
let prompt = build_prompt(&fm, &the_diff, &content_original);
if !no_git
&& let Err(e) = git::commit(file)
{
eprintln!("[stream] git commit skipped: {}", e);
}
eprintln!("[stream] Submitting to {} (streaming)...", agent_name);
let fork = fm.resume.is_none();
let model = model.or(fm.model.as_deref());
let chunks = streaming_agent.send_streaming(&prompt, fm.resume.as_deref(), fork, model)?;
let thinking_cfg = if thinking_enabled {
Some(ThinkingConfig {
target: thinking_target,
})
} else {
None
};
let result = stream_loop(file, chunks, interval, target, &content_original, thinking_cfg.as_ref())?;
if let Some(ref sid) = result.session_id {
let current = std::fs::read_to_string(file)?;
let updated = frontmatter::set_resume_id(¤t, sid)?;
crate::write::atomic_write_pub(file, &updated)?;
snapshot::save(file, &updated)?;
}
if !no_git
&& let Err(e) = git::commit(file)
{
eprintln!("[stream] git commit skipped: {}", e);
}
eprintln!("[stream] Stream complete for {}", file.display());
Ok(())
}
struct ThinkingConfig {
target: Option<String>,
}
struct StreamResult {
session_id: Option<String>,
}
fn stream_loop(
file: &Path,
chunks: Box<dyn Iterator<Item = Result<StreamChunk>>>,
interval_ms: u64,
target: &str,
baseline: &str,
thinking_cfg: Option<&ThinkingConfig>,
) -> Result<StreamResult> {
let buffer = Arc::new(Mutex::new(String::new()));
let thinking_buffer = Arc::new(Mutex::new(String::new()));
let (done_tx, done_rx) = mpsc::channel::<()>();
let timer_buffer = Arc::clone(&buffer);
let timer_thinking = Arc::clone(&thinking_buffer);
let file_path = file.to_path_buf();
let target_name = target.to_string();
let baseline_copy = baseline.to_string();
let timer_interval = Duration::from_millis(interval_ms);
let thinking_target = thinking_cfg.and_then(|c| c.target.clone());
let has_thinking = thinking_cfg.is_some();
let timer_handle = std::thread::spawn(move || {
let mut last_written = String::new();
let mut last_thinking = String::new();
loop {
let is_done = match done_rx.recv_timeout(timer_interval) {
Ok(()) => true,
Err(mpsc::RecvTimeoutError::Timeout) => false,
Err(mpsc::RecvTimeoutError::Disconnected) => true,
};
let text = timer_buffer.lock().unwrap().clone();
let thinking_text = if has_thinking {
timer_thinking.lock().unwrap().clone()
} else {
String::new()
};
if text != last_written && !text.is_empty() {
match flush_to_document(&file_path, &text, &target_name, &baseline_copy) {
Ok(()) => {
last_written = text;
if !is_done {
eprint!(".");
}
}
Err(e) => {
let label = if is_done { "final flush" } else { "flush" };
eprintln!("[stream] {} error: {}", label, e);
}
}
}
if has_thinking
&& thinking_text != last_thinking
&& !thinking_text.is_empty()
{
if let Some(ref tt) = thinking_target {
match flush_to_document(&file_path, &thinking_text, tt, &baseline_copy) {
Ok(()) => {
last_thinking = thinking_text;
}
Err(e) => {
eprintln!("[stream] thinking flush error: {}", e);
}
}
} else {
last_thinking = thinking_text;
}
}
if is_done {
return;
}
}
});
let mut session_id = None;
let mut chunk_count = 0;
for chunk_result in chunks {
let chunk = chunk_result.context("stream chunk error")?;
if let Some(ref thinking) = chunk.thinking
&& thinking_cfg.is_some()
{
let mut tbuf = thinking_buffer.lock().unwrap();
*tbuf = thinking.clone();
}
if !chunk.text.is_empty() {
let mut buf = buffer.lock().unwrap();
if thinking_cfg.is_some()
&& thinking_cfg.unwrap().target.is_none()
{
let thinking_text = thinking_buffer.lock().unwrap().clone();
if !thinking_text.is_empty() {
*buf = format!(
"<details>\n<summary>Thinking</summary>\n\n{}\n</details>\n\n{}",
thinking_text, chunk.text
);
} else {
*buf = chunk.text.clone();
}
} else {
*buf = chunk.text.clone();
}
chunk_count += 1;
}
if chunk.is_final {
session_id = chunk.session_id;
break;
}
}
let _ = done_tx.send(());
timer_handle.join().map_err(|_| anyhow::anyhow!("timer thread panicked"))?;
eprintln!("\n[stream] Received {} chunks", chunk_count);
let final_text = buffer.lock().unwrap().clone();
if !final_text.is_empty() {
recover::save_pending(file, &final_text)?;
flush_to_document(file, &final_text, target, baseline)?;
if let Some(cfg) = thinking_cfg
&& let Some(ref tt) = cfg.target
{
let final_thinking = thinking_buffer.lock().unwrap().clone();
if !final_thinking.is_empty() {
flush_to_document(file, &final_thinking, tt, baseline)?;
}
}
let content_ours = {
let patch = format!("<!-- patch:{} -->\n{}\n<!-- /patch:{} -->", target, final_text, target);
let (patches, unmatched) = crate::template::parse_patches(&patch)
.unwrap_or_default();
let mut mode_overrides = std::collections::HashMap::new();
mode_overrides.insert(target.to_string(), "replace".to_string());
crate::template::apply_patches_with_overrides(baseline, &patches, &unmatched, file, &mode_overrides)
.unwrap_or_else(|_| std::fs::read_to_string(file).unwrap_or_default())
};
snapshot::save(file, &content_ours)?;
let doc = crdt::CrdtDoc::from_text(&content_ours);
snapshot::save_crdt(file, &doc.encode_state())?;
recover::clear_pending(file)?;
}
Ok(StreamResult { session_id })
}
pub(crate) fn flush_to_document(
file: &Path,
text: &str,
target: &str,
_baseline: &str,
) -> Result<()> {
let patch_response = format!("<!-- patch:{} -->\n{}\n<!-- /patch:{} -->\n", target, text, target);
let (patches, unmatched) = template::parse_patches(&patch_response)
.context("failed to parse patch blocks")?;
if crate::write::try_ipc(file, &patches, &unmatched, None, None, None, None)? {
return Ok(());
}
let mut mode_overrides = std::collections::HashMap::new();
mode_overrides.insert(target.to_string(), "replace".to_string());
let lock_path = snapshot::lock_path_for(file)?;
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
fs2::FileExt::lock_exclusive(&lock_file)?;
let content_current = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let content_patched = template::apply_patches_with_overrides(
&content_current, &patches, &unmatched, file, &mode_overrides,
).context("failed to apply template patches")?;
crate::write::atomic_write_pub(file, &content_patched)?;
drop(lock_file);
Ok(())
}
fn build_prompt(fm: &frontmatter::Frontmatter, the_diff: &str, content: &str) -> String {
if fm.resume.is_some() {
format!(
"The user edited the session document. Here is the diff since the last run:\n\n\
<diff>\n{}\n</diff>\n\n\
The full document is now:\n\n\
<document>\n{}\n</document>\n\n\
Respond to the user's new content. Write your response in markdown.\n\
Format your response as patch blocks targeting document components.\n\
Example: <!-- patch:exchange -->\\nYour response\\n<!-- /patch:exchange -->",
the_diff, content
)
} else {
format!(
"The user is starting a session document. Here is the full document:\n\n\
<document>\n{}\n</document>\n\n\
Respond to the user's content. Write your response in markdown.\n\
Format your response as patch blocks targeting document components.\n\
Example: <!-- patch:exchange -->\\nYour response\\n<!-- /patch:exchange -->",
content
)
}
}
fn resolve_streaming(
name: &str,
config: Option<&crate::config::AgentConfig>,
env: Vec<(String, String)>,
) -> Result<Box<dyn StreamingAgent>> {
let (cmd, args) = match config {
Some(ac) => (Some(ac.command.clone()), Some(ac.args.clone())),
None => (None, None),
};
match name {
"claude" => Ok(Box::new(agent::claude::Claude::new(cmd, args).with_env(env))),
other => {
if config.is_some() {
Ok(Box::new(agent::claude::Claude::new(cmd, args).with_env(env)))
} else {
anyhow::bail!("Unknown streaming agent backend: {} (only claude supports streaming)", other)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::streaming::StreamChunk;
fn mock_chunks(chunks: Vec<StreamChunk>) -> Box<dyn Iterator<Item = Result<StreamChunk>>> {
Box::new(chunks.into_iter().map(Ok))
}
#[test]
fn flush_to_document_applies_patch() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n# Test\n\n<!-- agent:output -->\nOld content\n<!-- /agent:output -->\n";
std::fs::write(&doc, content).unwrap();
flush_to_document(&doc, "New streamed content", "output", content).unwrap();
let result = std::fs::read_to_string(&doc).unwrap();
assert!(result.contains("New streamed content"), "patched content missing: {}", result);
assert!(!result.contains("Old content"), "old content should be replaced: {}", result);
}
#[test]
fn flush_replaces_exchange_in_stream_mode() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n<!-- agent:exchange -->\nExisting\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, content).unwrap();
flush_to_document(&doc, "New content", "exchange", content).unwrap();
let result = std::fs::read_to_string(&doc).unwrap();
assert!(!result.contains("Existing"), "stream flush should replace, not append: {}", result);
assert!(result.contains("New content"), "new content should be present");
}
#[test]
fn flush_cumulative_does_not_duplicate() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n<!-- agent:exchange -->\nUser prompt\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, content).unwrap();
flush_to_document(&doc, "Hello", "exchange", content).unwrap();
flush_to_document(&doc, "Hello world", "exchange", content).unwrap();
let result = std::fs::read_to_string(&doc).unwrap();
assert!(result.contains("Hello world"), "cumulative text should be present: {}", result);
let hello_count = result.matches("Hello").count();
assert_eq!(hello_count, 1, "Hello should appear exactly once (replace, not append): {}", result);
}
#[test]
fn flush_preserves_other_components() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n# Test\n\n<!-- agent:status -->\nStatus line\n<!-- /agent:status -->\n\n<!-- agent:output -->\nOld\n<!-- /agent:output -->\n";
std::fs::write(&doc, content).unwrap();
flush_to_document(&doc, "New content", "output", content).unwrap();
let result = std::fs::read_to_string(&doc).unwrap();
assert!(result.contains("Status line"), "status component should be preserved");
assert!(result.contains("New content"), "output should be updated");
}
#[test]
fn stream_loop_processes_chunks() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("pending")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("crdt")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, content).unwrap();
let chunks = mock_chunks(vec![
StreamChunk { text: "Hello".to_string(), thinking: None, is_final: false, session_id: None },
StreamChunk { text: "Hello world".to_string(), thinking: None, is_final: false, session_id: None },
StreamChunk { text: "Hello world!".to_string(), thinking: None, is_final: true, session_id: Some("sess-1".to_string()) },
]);
let result = stream_loop(&doc, chunks, 100, "exchange", content, None).unwrap();
assert_eq!(result.session_id.as_deref(), Some("sess-1"));
let final_doc = std::fs::read_to_string(&doc).unwrap();
assert!(final_doc.contains("Hello world!"), "final text should be in document: {}", final_doc);
}
#[test]
fn stream_loop_empty_chunks() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("pending")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("crdt")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
std::fs::write(&doc, content).unwrap();
let chunks = mock_chunks(vec![
StreamChunk { text: String::new(), thinking: None, is_final: false, session_id: None },
StreamChunk { text: String::new(), thinking: None, is_final: true, session_id: None },
]);
let result = stream_loop(&doc, chunks, 100, "exchange", content, None).unwrap();
assert!(result.session_id.is_none());
}
#[test]
fn build_prompt_first_submit() {
let fm = frontmatter::Frontmatter {
resume: None,
..Default::default()
};
let prompt = build_prompt(&fm, "diff here", "doc content");
assert!(prompt.contains("starting a session"));
assert!(prompt.contains("doc content"));
assert!(!prompt.contains("diff here")); }
#[test]
fn build_prompt_resume() {
let fm = frontmatter::Frontmatter {
resume: Some("sess-123".to_string()),
..Default::default()
};
let prompt = build_prompt(&fm, "diff here", "doc content");
assert!(prompt.contains("edited the session document"));
assert!(prompt.contains("diff here"));
assert!(prompt.contains("doc content"));
}
#[test]
fn build_prompt_mentions_patch_blocks() {
let fm = frontmatter::Frontmatter::default();
let prompt = build_prompt(&fm, "diff", "content");
assert!(prompt.contains("patch:exchange"), "prompt should mention patch block format");
}
#[test]
fn stream_loop_thinking_to_separate_component() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("pending")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("crdt")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n<!-- agent:log -->\n<!-- /agent:log -->\n";
std::fs::write(&doc, content).unwrap();
let chunks = mock_chunks(vec![
StreamChunk {
text: "".to_string(),
thinking: Some("Let me think...".to_string()),
is_final: false,
session_id: None,
},
StreamChunk {
text: "The answer is 42.".to_string(),
thinking: Some("Let me think... Yes, 42.".to_string()),
is_final: true,
session_id: Some("sess-2".to_string()),
},
]);
let thinking_cfg = ThinkingConfig {
target: Some("log".to_string()),
};
let result = stream_loop(&doc, chunks, 100, "exchange", content, Some(&thinking_cfg)).unwrap();
assert_eq!(result.session_id.as_deref(), Some("sess-2"));
let final_doc = std::fs::read_to_string(&doc).unwrap();
assert!(final_doc.contains("The answer is 42."), "response text should be in exchange: {}", final_doc);
assert!(final_doc.contains("Yes, 42."), "thinking should be in log: {}", final_doc);
}
#[test]
fn stream_loop_thinking_interleaved() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("pending")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("crdt")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n<!-- agent:output -->\n<!-- /agent:output -->\n";
std::fs::write(&doc, content).unwrap();
let chunks = mock_chunks(vec![
StreamChunk {
text: "The answer.".to_string(),
thinking: Some("Reasoning here.".to_string()),
is_final: true,
session_id: None,
},
]);
let thinking_cfg = ThinkingConfig { target: None }; let result = stream_loop(&doc, chunks, 100, "output", content, Some(&thinking_cfg)).unwrap();
assert!(result.session_id.is_none());
let final_doc = std::fs::read_to_string(&doc).unwrap();
assert!(final_doc.contains("<details>"), "interleaved thinking should use details tag: {}", final_doc);
assert!(final_doc.contains("Reasoning here."), "thinking content should be present: {}", final_doc);
assert!(final_doc.contains("The answer."), "response text should be present: {}", final_doc);
}
#[test]
fn stream_loop_no_thinking_skips_thinking_blocks() {
let dir = tempfile::TempDir::new().unwrap();
let agent_doc_dir = dir.path().join(".agent-doc");
std::fs::create_dir_all(agent_doc_dir.join("snapshots")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("locks")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("pending")).unwrap();
std::fs::create_dir_all(agent_doc_dir.join("crdt")).unwrap();
let doc = dir.path().join("test.md");
let content = "---\nagent_doc_mode: stream\n---\n\n<!-- agent:output -->\n<!-- /agent:output -->\n";
std::fs::write(&doc, content).unwrap();
let chunks = mock_chunks(vec![
StreamChunk {
text: "Response only.".to_string(),
thinking: Some("Secret thoughts.".to_string()),
is_final: true,
session_id: None,
},
]);
let result = stream_loop(&doc, chunks, 100, "output", content, None).unwrap();
assert!(result.session_id.is_none());
let final_doc = std::fs::read_to_string(&doc).unwrap();
assert!(final_doc.contains("Response only."), "response should be present: {}", final_doc);
assert!(!final_doc.contains("Secret thoughts"), "thinking should NOT appear: {}", final_doc);
}
#[test]
fn mode_validation_rejects_non_crdt() {
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("test.md");
std::fs::write(&doc, "---\nagent_doc_format: template\nagent_doc_write: merge\n---\n\nBody\n").unwrap();
let config = Config::default();
let err = run(&doc, 2000, None, None, true, &config).unwrap_err();
assert!(err.to_string().contains("expected crdt"), "error: {}", err);
}
#[test]
fn content_ours_exchange_no_duplicate() {
let baseline = "\
---
agent_doc_format: template
agent_doc_write: crdt
---
<!-- agent:exchange -->
commit and push all rappstack packages and sites.
publish briantakita.me
<!-- /agent:exchange -->
";
let target = "exchange";
let final_text = "\
commit and push all rappstack packages and sites.
publish briantakita.me
### Re: commit and push
Done — all packages pushed.";
let patch = format!("<!-- patch:{} -->\n{}\n<!-- /patch:{} -->", target, final_text, target);
let (patches, unmatched) = crate::template::parse_patches(&patch).unwrap();
let mut mode_overrides = std::collections::HashMap::new();
mode_overrides.insert(target.to_string(), "replace".to_string());
let file = std::path::Path::new("test.md");
let content_ours = crate::template::apply_patches_with_overrides(
baseline, &patches, &unmatched, file, &mode_overrides,
).unwrap();
assert_eq!(
content_ours.matches("commit and push all rappstack packages and sites.").count(),
1,
"User prompt duplicated in content_ours:\n{}",
content_ours
);
assert_eq!(
content_ours.matches("publish briantakita.me").count(),
1,
"User prompt duplicated in content_ours:\n{}",
content_ours
);
assert!(content_ours.contains("Done — all packages pushed."));
}
#[test]
fn content_ours_exchange_duplicates_without_replace() {
let baseline = "\
---
agent_doc_format: template
agent_doc_write: crdt
---
<!-- agent:exchange -->
user prompt here
<!-- /agent:exchange -->
";
let target = "exchange";
let final_text = "user prompt here\n\nAgent response.";
let patch = format!("<!-- patch:{} -->\n{}\n<!-- /patch:{} -->", target, final_text, target);
let (patches, unmatched) = crate::template::parse_patches(&patch).unwrap();
let file = std::path::Path::new("test.md");
let content_no_override = crate::template::apply_patches(
baseline, &patches, &unmatched, file,
).unwrap();
assert_eq!(
content_no_override.matches("user prompt here").count(),
1,
"Expected dedup to prevent echo duplication in append mode:\n{}",
content_no_override
);
assert!(content_no_override.contains("Agent response."));
}
}