Skip to repository content1300 lines · 43.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:33:31.612Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
edit_session.rs
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