use std::path::{Path, PathBuf};
use termesh_agent::service::{AgentEvent, AgentRequest, AgentService};
use termesh_agent::{changeset_from_hunks, hunks_from_diff, rebase_hunks, Hunk};
use termesh_core::{BufferId, ProposalId};
use termesh_editor::{Buffer, EditSource, HunkState, Selection, Version};
use termesh_test_support::{ScriptedAgent, ScriptedUpdate};
const PATH: &str = "/proj/main.rs";
const ORIGINAL: &str = "fn main() {\n println!(\"hi\");\n}\n";
const PROPOSED: &str = "fn run() {\n println!(\"hi\");\n}\n";
fn buffer(text: &str) -> Buffer {
Buffer::from_text(BufferId::new(1), Some(PathBuf::from(PATH)), text)
}
#[derive(Default)]
struct ReadSet {
entries: Vec<(PathBuf, Version, String)>,
}
impl ReadSet {
fn record(&mut self, path: &Path, version: Version, text: &str) {
self.entries.push((path.to_path_buf(), version, text.to_string()));
}
fn anchor(&self, path: &Path, old_text: &str) -> Option<Version> {
self.entries
.iter()
.rev()
.find(|(p, _, text)| p == path && text == old_text)
.map(|(_, version, _)| *version)
}
fn served_text(&self, path: &Path) -> Option<&str> {
self.entries.iter().rev().find(|(p, ..)| p == path).map(|(_, _, t)| t.as_str())
}
}
fn run_turn(agent: &mut ScriptedAgent, buffer: &Buffer, reads: &mut ReadSet) -> Vec<AgentEvent> {
agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
let startup = agent.poll();
let session = startup
.iter()
.find_map(|event| match event {
AgentEvent::SessionStarted { session } => Some(*session),
_ => None,
})
.unwrap_or_else(|| panic!("expected a session, got {startup:?}"));
agent.send(AgentRequest::Prompt {
session,
text: "rename main to run".into(),
context: "project: proj (rust)".into(),
});
let mut collected = Vec::new();
loop {
let events = agent.poll();
if events.is_empty() {
return collected;
}
for event in events {
if let AgentEvent::ReadFileRequested { request, path, .. } = &event {
let text = buffer.text().to_string();
reads.record(path, buffer.version(), &text);
agent.send(AgentRequest::FileContents {
session,
request: *request,
path: path.clone(),
contents: Some(text),
});
}
collected.push(event);
}
}
}
fn script() -> ScriptedAgent {
ScriptedAgent::new().with_turn(vec![
ScriptedUpdate::Message("Renaming `main` to `run`.".into()),
ScriptedUpdate::ReadFile(PathBuf::from(PATH)),
ScriptedUpdate::Edit {
path: PathBuf::from(PATH),
old_text: Some(ORIGINAL.into()),
new_text: PROPOSED.into(),
},
ScriptedUpdate::End,
])
}
fn proposed(events: &[AgentEvent]) -> (ProposalId, String, String) {
events
.iter()
.find_map(|e| match e {
AgentEvent::ProposedEdit { proposal, old_text, new_text, .. } => {
Some((*proposal, old_text.clone().unwrap_or_default(), new_text.clone()))
}
_ => None,
})
.expect("the turn should have proposed an edit")
}
fn accept(buffer: &mut Buffer, proposal: ProposalId, hunks: &[Hunk]) {
let applicable: Vec<&Hunk> = hunks.iter().filter(|h| h.state.is_applicable()).collect();
if applicable.is_empty() {
return;
}
let changes = changeset_from_hunks(&applicable, buffer.text().len_chars());
let tx = buffer.transaction(changes, EditSource::Agent(proposal));
buffer.apply(&tx).expect("an accepted proposal should apply");
}
#[test]
fn the_agent_reads_the_live_buffer_not_the_disk() {
let mut buffer = buffer(ORIGINAL);
buffer.set_selection(Selection::point(0));
buffer.insert("// unsaved\n", EditSource::Keyboard).unwrap();
let mut agent = script();
let mut reads = ReadSet::default();
run_turn(&mut agent, &buffer, &mut reads);
let served = agent.served().first().expect("the agent asked for the file");
assert_eq!(
served.1.as_deref(),
Some(buffer.text().to_string().as_str()),
"the agent must see what the human sees, unsaved and all"
);
assert!(served.1.as_ref().unwrap().starts_with("// unsaved"));
}
#[test]
fn a_proposal_anchors_to_the_version_we_served() {
let mut buffer = buffer(ORIGINAL);
let mut agent = script();
let mut reads = ReadSet::default();
let events = run_turn(&mut agent, &buffer, &mut reads);
let (proposal, old_text, new_text) = proposed(&events);
let anchor = reads.anchor(Path::new(PATH), &old_text);
assert_eq!(anchor, Some(buffer.version()), "anchored to the revision we served");
let hunks = hunks_from_diff(&old_text, &new_text);
accept(&mut buffer, proposal, &hunks);
assert_eq!(buffer.text().to_string(), PROPOSED);
}
#[test]
fn a_proposal_still_applies_after_the_human_edits_elsewhere() {
let mut buffer = buffer(ORIGINAL);
let mut agent = script();
let mut reads = ReadSet::default();
let events = run_turn(&mut agent, &buffer, &mut reads);
let (proposal, old_text, new_text) = proposed(&events);
buffer.set_selection(Selection::point(0));
buffer.insert("// note\n", EditSource::Keyboard).unwrap();
assert_ne!(reads.anchor(Path::new(PATH), &old_text), Some(buffer.version()), "moved on");
let mut hunks = hunks_from_diff(&old_text, &new_text);
rebase_hunks(&mut hunks, &old_text, &buffer.text().to_string());
assert!(hunks.iter().all(|h| h.state == HunkState::Clean));
accept(&mut buffer, proposal, &hunks);
assert_eq!(buffer.text().to_string(), format!("// note\n{PROPOSED}"));
}
#[test]
fn a_proposal_conflicts_when_the_human_edits_the_same_line() {
let mut buffer = buffer(ORIGINAL);
let mut agent = script();
let mut reads = ReadSet::default();
let events = run_turn(&mut agent, &buffer, &mut reads);
let (proposal, old_text, new_text) = proposed(&events);
let target = ORIGINAL.find("main").unwrap();
buffer.edit(target, target + 4, "start", EditSource::Keyboard).unwrap();
let after_human = buffer.text().to_string();
let mut hunks = hunks_from_diff(&old_text, &new_text);
rebase_hunks(&mut hunks, &old_text, &after_human);
assert!(
hunks.iter().any(|h| matches!(h.state, HunkState::Conflicted(_))),
"got {:?}",
hunks.iter().map(|h| h.state).collect::<Vec<_>>()
);
accept(&mut buffer, proposal, &hunks);
assert_eq!(buffer.text().to_string(), after_human, "the human's edit survives untouched");
}
#[test]
fn a_proposal_settles_when_the_human_already_made_the_change() {
let mut buffer = buffer(ORIGINAL);
let mut agent = script();
let mut reads = ReadSet::default();
let events = run_turn(&mut agent, &buffer, &mut reads);
let (proposal, old_text, new_text) = proposed(&events);
let target = ORIGINAL.find("main").unwrap();
buffer.edit(target, target + 4, "run", EditSource::Keyboard).unwrap();
let after_human = buffer.text().to_string();
assert_eq!(after_human, PROPOSED, "the human made exactly the agent's change");
let mut hunks = hunks_from_diff(&old_text, &new_text);
rebase_hunks(&mut hunks, &old_text, &after_human);
assert!(hunks.iter().all(|h| h.state == HunkState::Satisfied));
accept(&mut buffer, proposal, &hunks);
assert_eq!(buffer.text().to_string(), PROPOSED, "and nothing is applied twice");
}
#[test]
fn a_proposal_we_never_served_anchors_by_content() {
let mut buffer = buffer(ORIGINAL);
let mut reads = ReadSet::default();
let mut agent = ScriptedAgent::new().with_turn(vec![ScriptedUpdate::Edit {
path: PathBuf::from(PATH),
old_text: Some(ORIGINAL.into()),
new_text: PROPOSED.into(),
}]);
let events = run_turn(&mut agent, &buffer, &mut reads);
let (proposal, old_text, new_text) = proposed(&events);
assert!(reads.served_text(Path::new(PATH)).is_none(), "we served nothing");
assert_eq!(reads.anchor(Path::new(PATH), &old_text), None, "so there is no version");
let mut hunks = hunks_from_diff(&old_text, &new_text);
rebase_hunks(&mut hunks, &old_text, &buffer.text().to_string());
accept(&mut buffer, proposal, &hunks);
assert_eq!(buffer.text().to_string(), PROPOSED, "content anchoring carries it");
}
#[test]
fn accept_then_undo_reverses_the_whole_proposal() {
let mut buffer = buffer(ORIGINAL);
let mut agent = script();
let mut reads = ReadSet::default();
let events = run_turn(&mut agent, &buffer, &mut reads);
let (proposal, old_text, new_text) = proposed(&events);
let hunks = hunks_from_diff(&old_text, &new_text);
accept(&mut buffer, proposal, &hunks);
assert_eq!(buffer.text().to_string(), PROPOSED);
assert!(buffer.undo(), "one keystroke");
assert_eq!(buffer.text().to_string(), ORIGINAL, "the agent's change, undone whole");
assert!(!buffer.can_undo(), "and it was a single step");
}
#[test]
fn a_partial_accept_applies_only_what_was_taken() {
let original = "one\ntwo\nthree\nfour\n";
let mut buffer = buffer(original);
let mut reads = ReadSet::default();
let mut agent = ScriptedAgent::new().with_turn(vec![
ScriptedUpdate::ReadFile(PathBuf::from(PATH)),
ScriptedUpdate::Edit {
path: PathBuf::from(PATH),
old_text: Some(original.into()),
new_text: "ONE\ntwo\nthree\nFOUR\n".into(),
},
ScriptedUpdate::End,
]);
let events = run_turn(&mut agent, &buffer, &mut reads);
let (proposal, old_text, new_text) = proposed(&events);
let mut hunks = hunks_from_diff(&old_text, &new_text);
assert_eq!(hunks.len(), 2, "two independent decisions");
hunks[1].state = HunkState::Conflicted(termesh_editor::ConflictReason::EditedInsideRange);
accept(&mut buffer, proposal, &hunks);
assert_eq!(buffer.text().to_string(), "ONE\ntwo\nthree\nfour\n");
}