Improve distinguishing user from agent edits (#34716)

We no longer rely on the `author` field to tell if a change was made by
the user or the agent. The `author` can be set to `User` in many
situations that are not really user-made edits, such as saving a file,
accepting a change, auto-formatting, and more. I started tracking and
fixing some of these cases, but found that inspecting changes in
`diff_base` is a more reliable method.

Also, we no longer show empty diffs. For example, if the user adds a
line and then removes the same line, the final diff is empty, even
though the buffer is marked as user-changed. Now we won't show such
edit.

There are still some issues to address:

- When a user edits within an unaccepted agent-written block, this
change becomes a part of the agent's edit. Rejecting this block will
lose user edits. It won't be displayed in project notifications, either.

- Accepting an agent block counts as a user-made edit.

- Agent start to call `project_notifications` tool after seeing enough
auto-calls.

Release Notes:

- N/A
This commit is contained in:
Oleksiy Syvokon 2025-07-22 14:23:50 +03:00 committed by GitHub
parent 3a651c546b
commit c7158f0bd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 42 additions and 37 deletions

View file

@ -47,7 +47,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use thiserror::Error; use thiserror::Error;
use util::{ResultExt as _, debug_panic, post_inc}; use util::{ResultExt as _, post_inc};
use uuid::Uuid; use uuid::Uuid;
use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
@ -1582,20 +1582,18 @@ impl Thread {
model: Arc<dyn LanguageModel>, model: Arc<dyn LanguageModel>,
cx: &mut App, cx: &mut App,
) -> Option<PendingToolUse> { ) -> Option<PendingToolUse> {
let action_log = self.action_log.read(cx); // Represent notification as a simulated `project_notifications` tool call
let tool_name = Arc::from("project_notifications");
let tool = self.tools.read(cx).tool(&tool_name, cx)?;
if !action_log.has_unnotified_user_edits() { if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) {
return None; return None;
} }
// Represent notification as a simulated `project_notifications` tool call if self
let tool_name = Arc::from("project_notifications"); .action_log
let Some(tool) = self.tools.read(cx).tool(&tool_name, cx) else { .update(cx, |log, cx| log.unnotified_user_edits(cx).is_none())
debug_panic!("`project_notifications` tool not found"); {
return None;
};
if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) {
return None; return None;
} }

View file

@ -51,23 +51,13 @@ impl ActionLog {
Some(self.tracked_buffers.get(buffer)?.snapshot.clone()) Some(self.tracked_buffers.get(buffer)?.snapshot.clone())
} }
pub fn has_unnotified_user_edits(&self) -> bool {
self.tracked_buffers
.values()
.any(|tracked| tracked.has_unnotified_user_edits)
}
/// Return a unified diff patch with user edits made since last read or notification /// Return a unified diff patch with user edits made since last read or notification
pub fn unnotified_user_edits(&self, cx: &Context<Self>) -> Option<String> { pub fn unnotified_user_edits(&self, cx: &Context<Self>) -> Option<String> {
if !self.has_unnotified_user_edits() { let diffs = self
return None;
}
let unified_diff = self
.tracked_buffers .tracked_buffers
.values() .values()
.filter_map(|tracked| { .filter_map(|tracked| {
if !tracked.has_unnotified_user_edits { if !tracked.may_have_unnotified_user_edits {
return None; return None;
} }
@ -95,9 +85,13 @@ impl ActionLog {
Some(result) Some(result)
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>();
.join("\n\n");
if diffs.is_empty() {
return None;
}
let unified_diff = diffs.join("\n\n");
Some(unified_diff) Some(unified_diff)
} }
@ -106,7 +100,7 @@ impl ActionLog {
pub fn flush_unnotified_user_edits(&mut self, cx: &Context<Self>) -> Option<String> { pub fn flush_unnotified_user_edits(&mut self, cx: &Context<Self>) -> Option<String> {
let patch = self.unnotified_user_edits(cx); let patch = self.unnotified_user_edits(cx);
self.tracked_buffers.values_mut().for_each(|tracked| { self.tracked_buffers.values_mut().for_each(|tracked| {
tracked.has_unnotified_user_edits = false; tracked.may_have_unnotified_user_edits = false;
tracked.last_seen_base = tracked.diff_base.clone(); tracked.last_seen_base = tracked.diff_base.clone();
}); });
patch patch
@ -185,7 +179,7 @@ impl ActionLog {
version: buffer.read(cx).version(), version: buffer.read(cx).version(),
diff, diff,
diff_update: diff_update_tx, diff_update: diff_update_tx,
has_unnotified_user_edits: false, may_have_unnotified_user_edits: false,
_open_lsp_handle: open_lsp_handle, _open_lsp_handle: open_lsp_handle,
_maintain_diff: cx.spawn({ _maintain_diff: cx.spawn({
let buffer = buffer.clone(); let buffer = buffer.clone();
@ -337,27 +331,34 @@ impl ActionLog {
let new_snapshot = buffer_snapshot.clone(); let new_snapshot = buffer_snapshot.clone();
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone(); let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
let edits = diff_snapshots(&old_snapshot, &new_snapshot); let edits = diff_snapshots(&old_snapshot, &new_snapshot);
if let ChangeAuthor::User = author let mut has_user_changes = false;
&& !edits.is_empty()
{
tracked_buffer.has_unnotified_user_edits = true;
}
async move { async move {
if let ChangeAuthor::User = author { if let ChangeAuthor::User = author {
apply_non_conflicting_edits( has_user_changes = apply_non_conflicting_edits(
&unreviewed_edits, &unreviewed_edits,
edits, edits,
&mut base_text, &mut base_text,
new_snapshot.as_rope(), new_snapshot.as_rope(),
); );
} }
(Arc::new(base_text.to_string()), base_text)
(Arc::new(base_text.to_string()), base_text, has_user_changes)
} }
}); });
anyhow::Ok(rebase) anyhow::Ok(rebase)
})??; })??;
let (new_base_text, new_diff_base) = rebase.await; let (new_base_text, new_diff_base, has_user_changes) = rebase.await;
this.update(cx, |this, _| {
let tracked_buffer = this
.tracked_buffers
.get_mut(buffer)
.context("buffer not tracked")
.unwrap();
tracked_buffer.may_have_unnotified_user_edits |= has_user_changes;
})?;
Self::update_diff( Self::update_diff(
this, this,
buffer, buffer,
@ -829,11 +830,12 @@ fn apply_non_conflicting_edits(
edits: Vec<Edit<u32>>, edits: Vec<Edit<u32>>,
old_text: &mut Rope, old_text: &mut Rope,
new_text: &Rope, new_text: &Rope,
) { ) -> bool {
let mut old_edits = patch.edits().iter().cloned().peekable(); let mut old_edits = patch.edits().iter().cloned().peekable();
let mut new_edits = edits.into_iter().peekable(); let mut new_edits = edits.into_iter().peekable();
let mut applied_delta = 0i32; let mut applied_delta = 0i32;
let mut rebased_delta = 0i32; let mut rebased_delta = 0i32;
let mut has_made_changes = false;
while let Some(mut new_edit) = new_edits.next() { while let Some(mut new_edit) = new_edits.next() {
let mut conflict = false; let mut conflict = false;
@ -883,8 +885,10 @@ fn apply_non_conflicting_edits(
&new_text.chunks_in_range(new_bytes).collect::<String>(), &new_text.chunks_in_range(new_bytes).collect::<String>(),
); );
applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32; applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
has_made_changes = true;
} }
} }
has_made_changes
} }
fn diff_snapshots( fn diff_snapshots(
@ -958,7 +962,7 @@ struct TrackedBuffer {
diff: Entity<BufferDiff>, diff: Entity<BufferDiff>,
snapshot: text::BufferSnapshot, snapshot: text::BufferSnapshot,
diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>, diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
has_unnotified_user_edits: bool, may_have_unnotified_user_edits: bool,
_open_lsp_handle: OpenLspBufferHandle, _open_lsp_handle: OpenLspBufferHandle,
_maintain_diff: Task<()>, _maintain_diff: Task<()>,
_subscription: Subscription, _subscription: Subscription,

View file

@ -278,6 +278,9 @@ impl Tool for EditFileTool {
.unwrap_or(false); .unwrap_or(false);
if format_on_save_enabled { if format_on_save_enabled {
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx);
})?;
let format_task = project.update(cx, |project, cx| { let format_task = project.update(cx, |project, cx| {
project.format( project.format(
HashSet::from_iter([buffer.clone()]), HashSet::from_iter([buffer.clone()]),