Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:33:31.612Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

edit_session.rs

1300 lines · 43.6 KB · rust
1mod reindent;
2mod streaming_fuzzy_matcher;
3mod streaming_parser;
4
5use super::tool_permissions::resolve_creatable_global_skill_path;
6use crate::{Thread, ToolCallEventStream};
7use acp_thread::Diff;
8use action_log::ActionLog;
9use agent_client_protocol::schema::v1::{self as acp, ToolCallLocation, ToolCallUpdateFields};
10use anyhow::Result;
11use collections::HashSet;
12use futures::{FutureExt, channel::oneshot};
13use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
14use language::language_settings::{self, FormatOnSave};
15use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry};
16use language_model::LanguageModelToolResultContent;
17use project::lsp_store::{FormatTrigger, LspFormatTarget};
18use project::{AgentLocation, Project, ProjectPath};
19use reindent::{IndentDelta, Reindenter, compute_indent_delta, compute_rest_indent_delta};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize, de::DeserializeOwned};
22use std::ops::Range;
23use std::path::PathBuf;
24use std::sync::Arc;
25use streaming_diff::{CharOperation, StreamingDiff};
26use streaming_fuzzy_matcher::{SearchMatch, SearchMatches, StreamingFuzzyMatcher};
27use streaming_parser::{EditEvent, StreamingParser, WriteEvent};
28use text::ToOffset;
29use ui::SharedString;
30use util::rel_path::RelPath;
31use util::{Deferred, ResultExt};
32
33/// Operating mode used internally by `EditSession`/`Pipeline` to choose between
34/// applying granular edits (the `edit_file` tool) or replacing/creating the
35/// entire file content (the `write_file` tool).
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub(crate) enum EditSessionMode {
38    Write,
39    Edit,
40}
41
42/// A single edit operation that replaces old text with new text
43/// Properly escape all text fields as valid JSON strings.
44/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings.
45#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
46pub struct Edit {
47    /// The exact text to find in the file. This will be matched using fuzzy matching
48    /// to handle minor differences in whitespace or formatting.
49    ///
50    /// Be minimal with replacements:
51    /// - For unique lines, include only those lines
52    /// - For non-unique lines, include enough context to identify them
53    pub old_text: String,
54    /// The text to replace it with
55    pub new_text: String,
56}
57
58#[derive(Clone, Default, Debug, Deserialize)]
59pub struct PartialEdit {
60    #[serde(default)]
61    pub old_text: Option<String>,
62    #[serde(default)]
63    pub new_text: Option<String>,
64}
65
66#[derive(Debug, Serialize, Deserialize)]
67#[serde(untagged)]
68pub enum EditSessionOutput {
69    Success {
70        #[serde(alias = "original_path")]
71        input_path: PathBuf,
72        new_text: String,
73        old_text: Arc<String>,
74        #[serde(default)]
75        diff: String,
76    },
77    Error {
78        error: String,
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        input_path: Option<PathBuf>,
81        #[serde(default, skip_serializing_if = "String::is_empty")]
82        diff: String,
83    },
84}
85
86impl EditSessionOutput {
87    pub fn error(error: impl Into<String>) -> Self {
88        Self::Error {
89            error: error.into(),
90            input_path: None,
91            diff: String::new(),
92        }
93    }
94}
95
96impl std::fmt::Display for EditSessionOutput {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            EditSessionOutput::Success {
100                diff, input_path, ..
101            } => {
102                if diff.is_empty() {
103                    write!(f, "No edits were made.")
104                } else {
105                    write!(f, "Edited {} successfully", input_path.display())
106                }
107            }
108            EditSessionOutput::Error {
109                error,
110                diff,
111                input_path,
112            } => {
113                write!(f, "{error}\n")?;
114                if let Some(input_path) = input_path
115                    && !diff.is_empty()
116                {
117                    write!(
118                        f,
119                        "Edited {}:\n\n```diff\n{diff}\n```",
120                        input_path.display()
121                    )
122                } else {
123                    write!(f, "No edits were made.")
124                }
125            }
126        }
127    }
128}
129
130impl From<EditSessionOutput> for LanguageModelToolResultContent {
131    fn from(output: EditSessionOutput) -> Self {
132        output.to_string().into()
133    }
134}
135
136pub(crate) struct EditSessionContext {
137    project: Entity<Project>,
138    thread: WeakEntity<Thread>,
139    action_log: Entity<ActionLog>,
140    language_registry: Arc<LanguageRegistry>,
141}
142
143impl EditSessionContext {
144    pub(crate) fn new(
145        project: Entity<Project>,
146        thread: WeakEntity<Thread>,
147        action_log: Entity<ActionLog>,
148        language_registry: Arc<LanguageRegistry>,
149    ) -> Self {
150        Self {
151            project,
152            thread,
153            action_log,
154            language_registry,
155        }
156    }
157
158    pub(crate) fn authorize(
159        &self,
160        tool_name: &str,
161        path: &PathBuf,
162        event_stream: &ToolCallEventStream,
163        cx: &mut App,
164    ) -> Task<Result<()>> {
165        super::tool_permissions::authorize_file_edit(
166            tool_name,
167            path,
168            &self.thread,
169            event_stream,
170            cx,
171        )
172    }
173
174    fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) {
175        let should_update_agent_location = self
176            .thread
177            .read_with(cx, |thread, _cx| !thread.is_subagent())
178            .unwrap_or_default();
179        if should_update_agent_location {
180            self.project.update(cx, |project, cx| {
181                project.set_agent_location(Some(AgentLocation { buffer, position }), cx);
182            });
183        }
184    }
185
186    async fn ensure_buffer_saved(&self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) {
187        let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
188            let settings = language_settings::LanguageSettings::for_buffer(buffer, cx);
189            settings.format_on_save != FormatOnSave::Off
190        });
191
192        if format_on_save_enabled {
193            self.project
194                .update(cx, |project, cx| {
195                    project.format(
196                        HashSet::from_iter([buffer.clone()]),
197                        LspFormatTarget::Buffers,
198                        false,
199                        FormatTrigger::Save,
200                        cx,
201                    )
202                })
203                .await
204                .log_err();
205        }
206
207        self.project
208            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
209            .await
210            .log_err();
211
212        self.action_log.update(cx, |log, cx| {
213            log.buffer_edited(buffer.clone(), cx);
214        });
215    }
216
217    pub(crate) fn initial_title_from_path(
218        &self,
219        path: &std::path::Path,
220        default: &str,
221        cx: &App,
222    ) -> SharedString {
223        let project = self.project.read(cx);
224        if let Some(project_path) = project.find_project_path(path, cx)
225            && let Some(short) = project.short_full_path_for_project_path(&project_path, cx)
226        {
227            return short.into();
228        }
229
230        let display = path.to_string_lossy();
231        if display.is_empty() {
232            default.into()
233        } else {
234            display.into_owned().into()
235        }
236    }
237
238    pub(crate) fn replay_output(
239        &self,
240        output: EditSessionOutput,
241        event_stream: ToolCallEventStream,
242        cx: &mut App,
243    ) -> Result<()> {
244        match output {
245            EditSessionOutput::Success {
246                input_path,
247                old_text,
248                new_text,
249                ..
250            } => {
251                event_stream.update_diff(cx.new(|cx| {
252                    Diff::finalized(
253                        input_path.to_string_lossy().into_owned(),
254                        Some(old_text.to_string()),
255                        new_text,
256                        self.language_registry.clone(),
257                        cx,
258                    )
259                }));
260                Ok(())
261            }
262            EditSessionOutput::Error { .. } => Ok(()),
263        }
264    }
265}
266
267pub(crate) enum EditSessionResult {
268    Completed(EditSession),
269    Failed {
270        error: String,
271        session: Option<EditSession>,
272    },
273}
274
275pub(crate) async fn run_session(
276    result: EditSessionResult,
277    event_stream: &ToolCallEventStream,
278    cx: &mut AsyncApp,
279) -> Result<EditSessionOutput, EditSessionOutput> {
280    match result {
281        EditSessionResult::Completed(session) => {
282            session
283                .context
284                .ensure_buffer_saved(&session.buffer, cx)
285                .await;
286            let (new_text, diff) = session.compute_new_text_and_diff(cx).await;
287            Ok(EditSessionOutput::Success {
288                old_text: session.old_text.clone(),
289                new_text,
290                input_path: session.input_path,
291                diff,
292            })
293        }
294        EditSessionResult::Failed {
295            error,
296            session: Some(session),
297        } => {
298            session
299                .context
300                .ensure_buffer_saved(&session.buffer, cx)
301                .await;
302            let (_new_text, diff) = session.compute_new_text_and_diff(cx).await;
303            if diff.is_empty() {
304                event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
305                    acp::ToolCallContent::Content(acp::Content::new(error.clone())),
306                ]));
307            }
308            Err(EditSessionOutput::Error {
309                error,
310                input_path: Some(session.input_path),
311                diff,
312            })
313        }
314        EditSessionResult::Failed {
315            error,
316            session: None,
317        } => {
318            event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
319                acp::ToolCallContent::Content(acp::Content::new(error.clone())),
320            ]));
321            Err(EditSessionOutput::Error {
322                error,
323                input_path: None,
324                diff: String::new(),
325            })
326        }
327    }
328}
329
330pub(crate) fn initial_title_from_partial_path<P>(
331    context: &EditSessionContext,
332    raw_input: serde_json::Value,
333    extract_path: impl FnOnce(&P) -> Option<String>,
334    default: &str,
335    cx: &App,
336) -> SharedString
337where
338    P: DeserializeOwned,
339{
340    if let Ok(partial) = serde_json::from_value::<P>(raw_input)
341        && let Some(raw_path) = extract_path(&partial)
342    {
343        let trimmed = raw_path.trim();
344        if !trimmed.is_empty() {
345            return context.initial_title_from_path(std::path::Path::new(trimmed), default, cx);
346        }
347    }
348    default.into()
349}
350
351pub(crate) struct EditSession {
352    abs_path: PathBuf,
353    pub(crate) input_path: PathBuf,
354    pub(crate) buffer: Entity<Buffer>,
355    pub(crate) old_text: Arc<String>,
356    diff: Entity<Diff>,
357    parser: StreamingParser,
358    pipeline: Pipeline,
359    context: Arc<EditSessionContext>,
360    _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
361}
362
363/// The destination of an edit session, identified by its absolute path on
364/// disk. `project_path` is `Some` for files that live inside one of the
365/// project's worktrees (i.e. that the standard project-path machinery can
366/// resolve), and `None` for global skill files reached through the
367/// `~/.agents/skills` allowlist.
368struct EditSessionTarget {
369    abs_path: PathBuf,
370    project_path: Option<ProjectPath>,
371}
372
373enum Pipeline {
374    Write(WritePipeline),
375    Edit(EditPipeline),
376}
377
378struct WritePipeline {
379    content_written: bool,
380}
381
382struct EditPipeline {
383    current_edit: Option<EditPipelineEntry>,
384    file_changed_since_last_read: bool,
385}
386
387enum EditPipelineEntry {
388    ResolvingOldText {
389        matcher: StreamingFuzzyMatcher,
390    },
391    StreamingNewText {
392        streaming_diff: StreamingDiff,
393        edit_cursor: usize,
394        reindenter: Reindenter,
395        original_snapshot: text::BufferSnapshot,
396        strip_trailing_newline: bool,
397    },
398}
399
400impl Pipeline {
401    fn new(mode: EditSessionMode, file_changed_since_last_read: bool) -> Self {
402        match mode {
403            EditSessionMode::Write => Self::Write(WritePipeline {
404                content_written: false,
405            }),
406            EditSessionMode::Edit => Self::Edit(EditPipeline {
407                current_edit: None,
408                file_changed_since_last_read,
409            }),
410        }
411    }
412}
413
414impl WritePipeline {
415    fn process_event(
416        &mut self,
417        event: &WriteEvent,
418        buffer: &Entity<Buffer>,
419        context: &EditSessionContext,
420        cx: &mut AsyncApp,
421    ) {
422        let WriteEvent::ContentChunk { chunk } = event;
423
424        let (buffer_id, buffer_len) =
425            buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len()));
426        let edit_range = if self.content_written {
427            buffer_len..buffer_len
428        } else {
429            0..buffer_len
430        };
431
432        agent_edit_buffer(
433            buffer,
434            [(edit_range, chunk.as_str())],
435            &context.action_log,
436            cx,
437        );
438        cx.update(|cx| {
439            context.set_agent_location(
440                buffer.downgrade(),
441                text::Anchor::max_for_buffer(buffer_id),
442                cx,
443            );
444        });
445        self.content_written = true;
446    }
447}
448
449impl EditPipeline {
450    fn ensure_resolving_old_text(&mut self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) {
451        if self.current_edit.is_none() {
452            let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
453            self.current_edit = Some(EditPipelineEntry::ResolvingOldText {
454                matcher: StreamingFuzzyMatcher::new(snapshot),
455            });
456        }
457    }
458
459    fn process_event(
460        &mut self,
461        event: &EditEvent,
462        buffer: &Entity<Buffer>,
463        diff: &Entity<Diff>,
464        abs_path: &PathBuf,
465        context: &EditSessionContext,
466        event_stream: &ToolCallEventStream,
467        cx: &mut AsyncApp,
468    ) -> Result<(), String> {
469        match event {
470            EditEvent::OldTextChunk {
471                chunk, done: false, ..
472            } => {
473                log::debug!("old_text_chunk: done=false, chunk='{}'", chunk);
474                self.ensure_resolving_old_text(buffer, cx);
475
476                if let Some(EditPipelineEntry::ResolvingOldText { matcher }) =
477                    &mut self.current_edit
478                    && !chunk.is_empty()
479                {
480                    if let Some(match_range) = matcher.push(chunk, None) {
481                        let anchor_range = buffer.read_with(cx, |buffer, _cx| {
482                            buffer.anchor_range_outside(match_range.clone())
483                        });
484                        diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
485
486                        cx.update(|cx| {
487                            let position = buffer.read(cx).anchor_before(match_range.end);
488                            context.set_agent_location(buffer.downgrade(), position, cx);
489                        });
490                    }
491                }
492            }
493            EditEvent::OldTextChunk {
494                edit_index,
495                chunk,
496                done: true,
497            } => {
498                log::debug!("old_text_chunk: done=true, chunk='{}'", chunk);
499
500                self.ensure_resolving_old_text(buffer, cx);
501
502                let Some(EditPipelineEntry::ResolvingOldText { matcher }) = &mut self.current_edit
503                else {
504                    return Ok(());
505                };
506
507                if !chunk.is_empty() {
508                    matcher.push(chunk, None);
509                }
510                let ResolvedMatch {
511                    search_match: SearchMatch { range, line_pairs },
512                    is_exact,
513                } = extract_match(
514                    matcher.finish(),
515                    buffer,
516                    edit_index,
517                    self.file_changed_since_last_read,
518                    cx,
519                )?;
520
521                let anchor_range =
522                    buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone()));
523                diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
524
525                let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
526
527                let line = snapshot.offset_to_point(range.start).row;
528                event_stream.update_fields(
529                    ToolCallUpdateFields::new()
530                        .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
531                );
532
533                let buffer_indent = snapshot.line_indent_for_row(line);
534                let query_lines = matcher.query_lines();
535                let query_indent = text::LineIndent::from_iter(
536                    query_lines
537                        .first()
538                        .map(|s| s.as_str())
539                        .unwrap_or("")
540                        .chars(),
541                );
542                let contextual_delta = compute_indent_delta(buffer_indent, query_indent);
543                let first_line_delta = if is_exact {
544                    // An exact range may start mid-line, where the retained prefix already
545                    // provides the first replacement line's indentation.
546                    IndentDelta::Spaces(0)
547                } else {
548                    contextual_delta
549                };
550                // Query row 0 is excluded: its delta is `first_line_delta`,
551                // which intentionally differs when the model stripped the
552                // first line's indentation.
553                let rest_delta = compute_rest_indent_delta(
554                    contextual_delta,
555                    line_pairs
556                        .iter()
557                        .filter(|(query_row, _)| *query_row != 0)
558                        .filter_map(|(query_row, buffer_row)| {
559                            let query_line = query_lines.get(*query_row as usize)?;
560                            Some((
561                                snapshot.line_indent_for_row(*buffer_row),
562                                text::LineIndent::from_iter(query_line.chars()),
563                            ))
564                        }),
565                );
566
567                let strip_trailing_newline = snapshot.contains_str_at(range.end, "\n");
568                let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::<String>();
569
570                log::debug!(
571                    "edit[{}] old_text matched at {}..{}: {:?}",
572                    edit_index,
573                    range.start,
574                    range.end,
575                    old_text_in_buffer,
576                );
577
578                let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
579                self.current_edit = Some(EditPipelineEntry::StreamingNewText {
580                    streaming_diff: StreamingDiff::new(old_text_in_buffer),
581                    edit_cursor: range.start,
582                    reindenter: Reindenter::with_deltas(first_line_delta, rest_delta),
583                    original_snapshot: text_snapshot,
584                    strip_trailing_newline,
585                });
586
587                cx.update(|cx| {
588                    let position = buffer.read(cx).anchor_before(range.end);
589                    context.set_agent_location(buffer.downgrade(), position, cx);
590                });
591            }
592            EditEvent::NewTextChunk {
593                chunk, done: false, ..
594            } => {
595                log::debug!("new_text_chunk: done=false, chunk='{}'", chunk);
596
597                let Some(EditPipelineEntry::StreamingNewText {
598                    streaming_diff,
599                    edit_cursor,
600                    reindenter,
601                    original_snapshot,
602                    ..
603                }) = &mut self.current_edit
604                else {
605                    return Ok(());
606                };
607
608                let reindented = reindenter.push(chunk);
609                if reindented.is_empty() {
610                    return Ok(());
611                }
612
613                let char_ops = streaming_diff.push_new(&reindented);
614                apply_char_operations(
615                    &char_ops,
616                    buffer,
617                    original_snapshot,
618                    edit_cursor,
619                    &context.action_log,
620                    cx,
621                );
622
623                let position = original_snapshot.anchor_before(*edit_cursor);
624                cx.update(|cx| {
625                    context.set_agent_location(buffer.downgrade(), position, cx);
626                });
627            }
628            EditEvent::NewTextChunk {
629                chunk, done: true, ..
630            } => {
631                log::debug!("new_text_chunk: done=true, chunk='{}'", chunk);
632
633                let Some(EditPipelineEntry::StreamingNewText {
634                    mut streaming_diff,
635                    mut edit_cursor,
636                    mut reindenter,
637                    original_snapshot,
638                    strip_trailing_newline,
639                }) = self.current_edit.take()
640                else {
641                    return Ok(());
642                };
643
644                let chunk = if strip_trailing_newline {
645                    chunk.strip_suffix('\n').unwrap_or(chunk)
646                } else {
647                    chunk
648                };
649                let mut final_text = reindenter.push(chunk);
650                final_text.push_str(&reindenter.finish());
651
652                log::debug!("new_text_chunk: done=true, final_text='{}'", final_text);
653
654                let mut char_ops = if final_text.is_empty() {
655                    Vec::new()
656                } else {
657                    streaming_diff.push_new(&final_text)
658                };
659                char_ops.extend(streaming_diff.finish());
660                apply_char_operations(
661                    &char_ops,
662                    buffer,
663                    &original_snapshot,
664                    &mut edit_cursor,
665                    &context.action_log,
666                    cx,
667                );
668
669                let position = original_snapshot.anchor_before(edit_cursor);
670                cx.update(|cx| {
671                    context.set_agent_location(buffer.downgrade(), position, cx);
672                });
673            }
674        }
675        Ok(())
676    }
677}
678
679impl EditSession {
680    pub(crate) async fn new(
681        path: PathBuf,
682        mode: EditSessionMode,
683        tool_name: &str,
684        context: Arc<EditSessionContext>,
685        event_stream: &ToolCallEventStream,
686        cx: &mut AsyncApp,
687    ) -> Result<Self, String> {
688        let target = if let Some(abs_path) =
689            resolve_global_skill_path_for_edit_session(mode, &path, &context, cx).await?
690        {
691            EditSessionTarget {
692                abs_path,
693                project_path: None,
694            }
695        } else {
696            let project_path = cx.update(|cx| resolve_path(mode, &path, &context.project, cx))?;
697
698            let Some(abs_path) =
699                cx.update(|cx| context.project.read(cx).absolute_path(&project_path, cx))
700            else {
701                return Err(format!(
702                    "Worktree at '{}' does not exist",
703                    path.to_string_lossy()
704                ));
705            };
706
707            EditSessionTarget {
708                abs_path,
709                project_path: Some(project_path),
710            }
711        };
712        let EditSessionTarget {
713            abs_path,
714            project_path,
715        } = target;
716
717        event_stream.update_fields(
718            ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]),
719        );
720
721        cx.update(|cx| context.authorize(tool_name, &path, event_stream, cx))
722            .await
723            .map_err(|e| e.to_string())?;
724
725        let buffer = match project_path {
726            Some(project_path) => context
727                .project
728                .update(cx, |project, cx| project.open_buffer(project_path, cx))
729                .await
730                .map_err(|e| e.to_string())?,
731            None => context
732                .project
733                .update(cx, |project, cx| {
734                    project.open_local_buffer(abs_path.clone(), cx)
735                })
736                .await
737                .map_err(|e| e.to_string())?,
738        };
739
740        let file_changed_since_last_read =
741            ensure_buffer_saved(&buffer, &abs_path, mode, &context, event_stream, cx).await?;
742
743        let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
744        event_stream.update_diff(diff.clone());
745        let finalize_diff_guard = util::defer(Box::new({
746            let diff = diff.downgrade();
747            let mut cx = cx.clone();
748            move || {
749                diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
750            }
751        }) as Box<dyn FnOnce()>);
752
753        context.action_log.update(cx, |log, cx| match mode {
754            EditSessionMode::Write => log.buffer_created(buffer.clone(), cx),
755            EditSessionMode::Edit => log.buffer_read(buffer.clone(), cx),
756        });
757
758        let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
759        let old_text = cx
760            .background_spawn({
761                let old_snapshot = old_snapshot.clone();
762                async move { Arc::new(old_snapshot.text()) }
763            })
764            .await;
765
766        Ok(Self {
767            abs_path,
768            input_path: path,
769            buffer,
770            old_text,
771            diff,
772            parser: StreamingParser::default(),
773            pipeline: Pipeline::new(mode, file_changed_since_last_read),
774            context,
775            _finalize_diff_guard: finalize_diff_guard,
776        })
777    }
778
779    pub(crate) async fn finalize_edit(
780        &mut self,
781        edits: Vec<Edit>,
782        event_stream: &ToolCallEventStream,
783        cx: &mut AsyncApp,
784    ) -> Result<(), String> {
785        let Self {
786            abs_path,
787            buffer,
788            diff,
789            parser,
790            pipeline,
791            context,
792            ..
793        } = self;
794        let Pipeline::Edit(edit_pipeline) = pipeline else {
795            return Err("Cannot finalize edits on a write session".to_string());
796        };
797
798        for event in &parser.finalize_edits(&edits) {
799            edit_pipeline.process_event(
800                event,
801                buffer,
802                diff,
803                abs_path,
804                context,
805                event_stream,
806                cx,
807            )?;
808        }
809
810        if log::log_enabled!(log::Level::Debug) {
811            log::debug!("Got edits:");
812            for edit in &edits {
813                log::debug!(
814                    "  old_text: '{}', new_text: '{}'",
815                    edit.old_text.replace('\n', "\\n"),
816                    edit.new_text.replace('\n', "\\n")
817                );
818            }
819        }
820        Ok(())
821    }
822
823    pub(crate) async fn finalize_write(
824        &mut self,
825        content: &str,
826        cx: &mut AsyncApp,
827    ) -> Result<(), String> {
828        let Self {
829            buffer,
830            parser,
831            pipeline,
832            context,
833            ..
834        } = self;
835        let Pipeline::Write(write) = pipeline else {
836            return Err("Cannot finalize a write on an edit session".to_string());
837        };
838
839        for event in &parser.finalize_content(content) {
840            write.process_event(event, buffer, context, cx);
841        }
842        Ok(())
843    }
844
845    async fn compute_new_text_and_diff(&self, cx: &mut AsyncApp) -> (String, String) {
846        let new_snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
847        let (new_text, unified_diff) = cx
848            .background_spawn({
849                let new_snapshot = new_snapshot.clone();
850                let old_text = self.old_text.clone();
851                async move {
852                    let new_text = new_snapshot.text();
853                    let diff = language::unified_diff(&old_text, &new_text);
854                    (new_text, diff)
855                }
856            })
857            .await;
858        (new_text, unified_diff)
859    }
860
861    pub(crate) fn process_edit(
862        &mut self,
863        edits: Option<&[PartialEdit]>,
864        event_stream: &ToolCallEventStream,
865        cx: &mut AsyncApp,
866    ) -> Result<(), String> {
867        let Self {
868            abs_path,
869            buffer,
870            diff,
871            parser,
872            pipeline,
873            context,
874            ..
875        } = self;
876        let Pipeline::Edit(edit_pipeline) = pipeline else {
877            return Err("Cannot apply partial edits on a write session".to_string());
878        };
879        let Some(edits) = edits else {
880            return Ok(());
881        };
882        for event in &parser.push_edits(edits) {
883            edit_pipeline.process_event(
884                event,
885                buffer,
886                diff,
887                abs_path,
888                context,
889                event_stream,
890                cx,
891            )?;
892        }
893        Ok(())
894    }
895
896    pub(crate) fn process_write(
897        &mut self,
898        content: Option<&str>,
899        cx: &mut AsyncApp,
900    ) -> Result<(), String> {
901        let Self {
902            buffer,
903            parser,
904            pipeline,
905            context,
906            ..
907        } = self;
908        let Pipeline::Write(write) = pipeline else {
909            return Err("Cannot apply partial content on an edit session".to_string());
910        };
911        let Some(content) = content else {
912            return Ok(());
913        };
914        for event in &parser.push_content(content) {
915            write.process_event(event, buffer, context, cx);
916        }
917        Ok(())
918    }
919}
920
921fn apply_char_operations(
922    ops: &[CharOperation],
923    buffer: &Entity<Buffer>,
924    snapshot: &text::BufferSnapshot,
925    edit_cursor: &mut usize,
926    action_log: &Entity<ActionLog>,
927    cx: &mut AsyncApp,
928) {
929    let mut edits: Vec<_> = Vec::new();
930    for op in ops {
931        match op {
932            CharOperation::Insert { text } => {
933                let anchor = snapshot.anchor_after(*edit_cursor);
934                edits.push((anchor..anchor, text.as_str().into()));
935            }
936            CharOperation::Delete { bytes } => {
937                let delete_end = *edit_cursor + bytes;
938                let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end);
939                edits.push((anchor_range, Arc::<str>::from("")));
940                *edit_cursor = delete_end;
941            }
942            CharOperation::Keep { bytes } => {
943                *edit_cursor += bytes;
944            }
945        }
946    }
947    if !edits.is_empty() {
948        agent_edit_buffer(buffer, edits, action_log, cx);
949    }
950}
951
952struct ResolvedMatch {
953    search_match: SearchMatch,
954    is_exact: bool,
955}
956
957fn extract_match(
958    matches: SearchMatches,
959    buffer: &Entity<Buffer>,
960    edit_index: &usize,
961    file_changed_since_last_read: bool,
962    cx: &mut AsyncApp,
963) -> Result<ResolvedMatch, String> {
964    let (matches, is_exact) = match matches {
965        SearchMatches::Exact(matches) => (matches, true),
966        SearchMatches::Fuzzy(matches) => (matches, false),
967    };
968    let file_changed_since_last_read_message = if file_changed_since_last_read {
969        " The file has changed on disk since you last read it."
970    } else {
971        ""
972    };
973
974    match matches.as_slice() {
975        [] => Err(format!(
976            "Could not find matching text for edit at index {}. \
977                The old_text did not match any content in the file.{} \
978                Please read the file again to get the current content.",
979            edit_index, file_changed_since_last_read_message,
980        )),
981        [search_match] => Ok(ResolvedMatch {
982            search_match: search_match.clone(),
983            is_exact,
984        }),
985        _ => {
986            let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
987            let lines = matches
988                .iter()
989                .map(|search_match| {
990                    (snapshot.offset_to_point(search_match.range.start).row + 1).to_string()
991                })
992                .collect::<Vec<_>>()
993                .join(", ");
994            Err(format!(
995                "Edit {} matched multiple locations in the file at lines: {}. \
996                    Please provide more context in old_text to uniquely \
997                    identify the location.",
998                edit_index, lines
999            ))
1000        }
1001    }
1002}
1003
1004/// Edits a buffer and reports the edit to the action log in the same effect
1005/// cycle. This ensures the action log's subscription handler sees the version
1006/// already updated by `buffer_edited`, so it does not misattribute the agent's
1007/// edit as a user edit.
1008fn agent_edit_buffer<I, S, T>(
1009    buffer: &Entity<Buffer>,
1010    edits: I,
1011    action_log: &Entity<ActionLog>,
1012    cx: &mut AsyncApp,
1013) where
1014    I: IntoIterator<Item = (Range<S>, T)>,
1015    S: ToOffset,
1016    T: Into<Arc<str>>,
1017{
1018    cx.update(|cx| {
1019        buffer.update(cx, |buffer, cx| {
1020            buffer.start_transaction();
1021            buffer.edit(edits, None, cx);
1022            buffer.end_transaction_with_source(BufferEditSource::Agent, cx);
1023        });
1024        action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1025    });
1026}
1027
1028async fn ensure_buffer_saved(
1029    buffer: &Entity<Buffer>,
1030    abs_path: &PathBuf,
1031    mode: EditSessionMode,
1032    context: &EditSessionContext,
1033    event_stream: &ToolCallEventStream,
1034    cx: &mut AsyncApp,
1035) -> Result<bool, String> {
1036    let last_read_mtime = context
1037        .action_log
1038        .read_with(cx, |log, _| log.file_read_time(abs_path));
1039    let (current_mtime, is_dirty) = buffer.read_with(cx, |buffer, _cx| {
1040        let current = buffer.file().and_then(|file| file.disk_state().mtime());
1041        let dirty = buffer.is_dirty();
1042        (current, dirty)
1043    });
1044
1045    if is_dirty {
1046        resolve_dirty_buffer(buffer, mode, context, event_stream, cx).await?;
1047    }
1048
1049    if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime)
1050        && current != last_read
1051    {
1052        return Ok(true);
1053    }
1054
1055    Ok(false)
1056}
1057
1058/// Prompts the user about how to handle a dirty buffer that the agent
1059/// wants to edit (`EditSessionMode::Edit`) or overwrite
1060/// (`EditSessionMode::Write`), and performs the chosen action so the
1061/// edit session can proceed (or returns `Err` to cancel).
1062///
1063/// If the user resolves the dirty state externally (e.g. cmd-s or
1064/// reload) while the prompt is visible, the prompt is dismissed
1065/// automatically.
1066async fn resolve_dirty_buffer(
1067    buffer: &Entity<Buffer>,
1068    mode: EditSessionMode,
1069    context: &EditSessionContext,
1070    event_stream: &ToolCallEventStream,
1071    cx: &mut AsyncApp,
1072) -> Result<(), String> {
1073    let (manual_resolve_tx, manual_resolve_rx) = oneshot::channel::<()>();
1074    let _buffer_subscription = cx.update(|cx| {
1075        let mut tx = Some(manual_resolve_tx);
1076        cx.subscribe(buffer, move |buffer, event: &BufferEvent, cx| {
1077            if matches!(
1078                event,
1079                BufferEvent::Saved | BufferEvent::Reloaded | BufferEvent::DirtyChanged
1080            ) && !buffer.read(cx).is_dirty()
1081                && let Some(tx) = tx.take()
1082            {
1083                tx.send(()).ok();
1084            }
1085        })
1086    });
1087
1088    let prompt_kind = match mode {
1089        EditSessionMode::Edit => super::tool_permissions::DirtyBufferPromptKind::Edit,
1090        EditSessionMode::Write => super::tool_permissions::DirtyBufferPromptKind::Overwrite,
1091    };
1092    let prompt = cx.update(|cx| {
1093        super::tool_permissions::authorize_dirty_buffer(prompt_kind, event_stream, cx)
1094    });
1095
1096    let decision = futures::select_biased! {
1097        _ = manual_resolve_rx.fuse() => {
1098            None
1099        }
1100        decision = prompt.fuse() => {
1101            Some(decision.map_err(|e| e.to_string())?)
1102        }
1103    };
1104
1105    let Some(decision) = decision else {
1106        let outcome = match mode {
1107            EditSessionMode::Edit => acp_thread::SelectedPermissionOutcome::new(
1108                acp::PermissionOptionId::new("save"),
1109                acp::PermissionOptionKind::AllowOnce,
1110            ),
1111            EditSessionMode::Write => acp_thread::SelectedPermissionOutcome::new(
1112                acp::PermissionOptionId::new("keep"),
1113                acp::PermissionOptionKind::RejectOnce,
1114            ),
1115        };
1116        event_stream.resolve_authorization(outcome);
1117        return match mode {
1118            EditSessionMode::Edit => Ok(()),
1119            EditSessionMode::Write => Err(
1120                "The user saved their unsaved changes while the prompt was visible; \
1121                 the file overwrite was cancelled to preserve them. Ask the user how \
1122                 they'd like to proceed before retrying."
1123                    .to_string(),
1124            ),
1125        };
1126    };
1127
1128    match decision {
1129        super::tool_permissions::DirtyBufferDecision::Save => {
1130            context
1131                .project
1132                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1133                .await
1134                .map_err(|e| format!("Failed to save buffer: {e}"))?;
1135        }
1136        super::tool_permissions::DirtyBufferDecision::Discard => {
1137            context
1138                .project
1139                .update(cx, |project, cx| {
1140                    project.reload_buffers(HashSet::from_iter([buffer.clone()]), false, cx)
1141                })
1142                .await
1143                .map_err(|e| format!("Failed to discard unsaved changes: {e}"))?;
1144        }
1145        super::tool_permissions::DirtyBufferDecision::Keep => {
1146            let error = "The user chose to keep their unsaved changes; the file overwrite \
1147             was cancelled. Ask the user how they'd like to proceed before \
1148             retrying."
1149                .to_string();
1150            event_stream.update_fields(
1151                acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]),
1152            );
1153            return Err(error);
1154        }
1155    }
1156    Ok(())
1157}
1158
1159/// Mirrors [`resolve_path`]'s pre-auth validation for the global-skill
1160/// branch: returns `Ok(Some(abs_path))` if the path lives under
1161/// `~/.agents/skills` and is in a valid state for the requested mode,
1162/// `Ok(None)` if the path isn't a global skill at all (so the caller should
1163/// fall through to project-path resolution), or `Err(message)` if the path
1164/// is a global skill but can't be used (missing in Edit mode, parent
1165/// missing in Write mode, etc.).
1166///
1167/// Errors returned from here surface to the model as tool-result errors
1168/// without prompting the user — same contract as [`resolve_path`]. The
1169/// idea is that "file doesn't exist" or "parent isn't a directory" are
1170/// model mistakes, not decisions the user should be asked to approve.
1171async fn resolve_global_skill_path_for_edit_session(
1172    mode: EditSessionMode,
1173    path: &PathBuf,
1174    context: &EditSessionContext,
1175    cx: &mut AsyncApp,
1176) -> Result<Option<PathBuf>, String> {
1177    let fs = context
1178        .project
1179        .read_with(cx, |project, _cx| project.fs().clone());
1180    let Some(abs_path) = resolve_creatable_global_skill_path(path, fs.as_ref()).await else {
1181        return Ok(None);
1182    };
1183
1184    match mode {
1185        EditSessionMode::Edit => {
1186            let metadata = fs
1187                .metadata(&abs_path)
1188                .await
1189                .map_err(|e| format!("Can't edit file: {e}"))?
1190                .ok_or_else(|| "Can't edit file: path not found".to_string())?;
1191            if metadata.is_dir {
1192                return Err("Can't edit file: path is a directory".to_string());
1193            }
1194        }
1195        EditSessionMode::Write => {
1196            if let Some(metadata) = fs
1197                .metadata(&abs_path)
1198                .await
1199                .map_err(|e| format!("Can't write to file: {e}"))?
1200            {
1201                if metadata.is_dir {
1202                    return Err("Can't write to file: path is a directory".to_string());
1203                }
1204            } else {
1205                let parent_path = abs_path
1206                    .parent()
1207                    .ok_or_else(|| "Can't create file: incorrect path".to_string())?;
1208                let parent_metadata = fs
1209                    .metadata(parent_path)
1210                    .await
1211                    .map_err(|e| format!("Can't create file: {e}"))?
1212                    .ok_or_else(|| {
1213                        "Can't create file: parent directory doesn't exist".to_string()
1214                    })?;
1215                if !parent_metadata.is_dir {
1216                    return Err("Can't create file: parent is not a directory".to_string());
1217                }
1218            }
1219        }
1220    }
1221
1222    Ok(Some(abs_path))
1223}
1224
1225fn resolve_path(
1226    mode: EditSessionMode,
1227    path: &PathBuf,
1228    project: &Entity<Project>,
1229    cx: &mut App,
1230) -> Result<ProjectPath, String> {
1231    let project = project.read(cx);
1232
1233    match mode {
1234        EditSessionMode::Edit => {
1235            let path = project
1236                .find_project_path(&path, cx)
1237                .ok_or_else(|| "Can't edit file: path not found".to_string())?;
1238
1239            let entry = project
1240                .entry_for_path(&path, cx)
1241                .ok_or_else(|| "Can't edit file: path not found".to_string())?;
1242
1243            if entry.is_file() {
1244                Ok(path)
1245            } else {
1246                Err("Can't edit file: path is a directory".to_string())
1247            }
1248        }
1249        EditSessionMode::Write => {
1250            if let Some(path) = project.find_project_path(&path, cx)
1251                && let Some(entry) = project.entry_for_path(&path, cx)
1252            {
1253                if entry.is_file() {
1254                    return Ok(path);
1255                } else {
1256                    return Err("Can't write to file: path is a directory".to_string());
1257                }
1258            }
1259
1260            let parent_path = path
1261                .parent()
1262                .ok_or_else(|| "Can't create file: incorrect path".to_string())?;
1263
1264            let parent_project_path = project.find_project_path(&parent_path, cx);
1265
1266            let parent_entry = parent_project_path
1267                .as_ref()
1268                .and_then(|path| project.entry_for_path(path, cx))
1269                .ok_or_else(|| "Can't create file: parent directory doesn't exist")?;
1270
1271            if !parent_entry.is_dir() {
1272                return Err("Can't create file: parent is not a directory".to_string());
1273            }
1274
1275            let file_name = path
1276                .file_name()
1277                .and_then(|file_name| file_name.to_str())
1278                .and_then(|file_name| RelPath::from_unix_str(file_name).ok())
1279                .ok_or_else(|| "Can't create file: invalid filename".to_string())?;
1280
1281            let new_file_path = parent_project_path.map(|parent| ProjectPath {
1282                path: parent.path.join(file_name).into(),
1283                ..parent
1284            });
1285
1286            new_file_path.ok_or_else(|| "Can't create file".to_string())
1287        }
1288    }
1289}
1290
1291#[cfg(test)]
1292pub(crate) async fn test_resolve_path(
1293    mode: &EditSessionMode,
1294    path: &str,
1295    project: &Entity<Project>,
1296    cx: &mut gpui::TestAppContext,
1297) -> Result<ProjectPath, String> {
1298    cx.update(|cx| resolve_path(*mode, &PathBuf::from(path), project, cx))
1299}
1300
Served at tenant.openagents/omega Member data and write actions are omitted.