Skip to repository content1467 lines · 55.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:36:16.079Z 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
write_file_tool.rs
1use super::edit_session::{
2 EditSession, EditSessionContext, EditSessionMode, EditSessionOutput, EditSessionResult,
3 initial_title_from_partial_path, run_session,
4};
5use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload};
6use action_log::ActionLog;
7use agent_client_protocol::schema::v1 as acp;
8use futures::FutureExt as _;
9use gpui::{App, AsyncApp, Entity, Task, WeakEntity};
10use language::LanguageRegistry;
11use project::Project;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15use std::sync::Arc;
16use ui::SharedString;
17
18const DEFAULT_UI_TEXT: &str = "Writing file";
19
20/// This is a tool for creating a new file or overwriting an existing file with completely new contents.
21///
22/// To make granular edits to an existing file, prefer the `edit_file` tool instead.
23///
24/// Before using this tool, verify the directory path is correct (only applicable when creating new files). Use the `list_directory` tool to verify the parent directory exists and is the correct location
25///
26/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
27#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
28pub struct WriteFileToolInput {
29 /// The full path of the file to create or overwrite in the project.
30 ///
31 /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories, unless it's a global agent skill under `~/.agents/skills`.
32 ///
33 /// The following examples assume we have two root directories in the project:
34 /// - /a/b/backend
35 /// - /c/d/frontend
36 ///
37 /// <example>
38 /// `backend/src/main.rs`
39 ///
40 /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
41 /// </example>
42 ///
43 /// <example>
44 /// `frontend/db.js`
45 /// </example>
46 ///
47 /// <example>
48 /// To create or overwrite a global agent skill file, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill/SKILL.md`.
49 /// </example>
50 pub path: PathBuf,
51
52 /// The entire content for the file.
53 pub content: String,
54}
55
56#[derive(Clone, Default, Debug, Deserialize)]
57struct WriteFileToolPartialInput {
58 #[serde(default)]
59 path: Option<String>,
60 #[serde(default)]
61 content: Option<String>,
62}
63
64pub struct WriteFileTool {
65 session_context: Arc<EditSessionContext>,
66}
67
68impl WriteFileTool {
69 pub fn new(
70 project: Entity<Project>,
71 thread: WeakEntity<Thread>,
72 action_log: Entity<ActionLog>,
73 language_registry: Arc<LanguageRegistry>,
74 ) -> Self {
75 Self {
76 session_context: Arc::new(EditSessionContext::new(
77 project,
78 thread,
79 action_log,
80 language_registry,
81 )),
82 }
83 }
84
85 async fn process_streaming_writes(
86 &self,
87 input: &mut ToolInput<WriteFileToolInput>,
88 event_stream: &ToolCallEventStream,
89 cx: &mut AsyncApp,
90 ) -> EditSessionResult {
91 let mut session: Option<EditSession> = None;
92 let mut last_path: Option<String> = None;
93
94 loop {
95 futures::select! {
96 payload = input.next().fuse() => {
97 match payload {
98 Ok(payload) => match payload {
99 ToolInputPayload::Partial(partial) => {
100 if let Ok(parsed) = serde_json::from_value::<WriteFileToolPartialInput>(partial) {
101 let path_complete = parsed.path.is_some()
102 && parsed.path.as_ref() == last_path.as_ref();
103
104 last_path = parsed.path.clone();
105
106 if session.is_none()
107 && path_complete
108 && let Some(path) = parsed.path.as_ref()
109 {
110 match EditSession::new(
111 PathBuf::from(path),
112 EditSessionMode::Write,
113 Self::NAME,
114 self.session_context.clone(),
115 event_stream,
116 cx,
117 )
118 .await
119 {
120 Ok(created_session) => session = Some(created_session),
121 Err(error) => {
122 log::error!("Failed to create edit session: {}", error);
123 return EditSessionResult::Failed {
124 error,
125 session: None,
126 };
127 }
128 }
129 }
130
131 if let Some(current_session) = &mut session
132 && let Err(error) = current_session.process_write(parsed.content.as_deref(), cx)
133 {
134 log::error!("Failed to process write: {}", error);
135 return EditSessionResult::Failed { error, session };
136 }
137 }
138 }
139 ToolInputPayload::Full(full_input) => {
140 let mut session = if let Some(session) = session {
141 session
142 } else {
143 match EditSession::new(
144 full_input.path.clone(),
145 EditSessionMode::Write,
146 Self::NAME,
147 self.session_context.clone(),
148 event_stream,
149 cx,
150 )
151 .await
152 {
153 Ok(created_session) => created_session,
154 Err(error) => {
155 log::error!("Failed to create edit session: {}", error);
156 return EditSessionResult::Failed {
157 error,
158 session: None,
159 };
160 }
161 }
162 };
163
164 return match session.finalize_write(&full_input.content, cx).await {
165 Ok(()) => EditSessionResult::Completed(session),
166 Err(error) => {
167 log::error!("Failed to finalize write: {}", error);
168 EditSessionResult::Failed {
169 error,
170 session: Some(session),
171 }
172 }
173 };
174 }
175 ToolInputPayload::InvalidJson { error_message } => {
176 log::error!("Received invalid JSON: {error_message}");
177 return EditSessionResult::Failed {
178 error: error_message,
179 session,
180 };
181 }
182 },
183 Err(error) => {
184 return EditSessionResult::Failed {
185 error: error.to_string(),
186 session,
187 };
188 }
189 }
190 }
191 _ = event_stream.cancelled_by_user().fuse() => {
192 return EditSessionResult::Failed {
193 error: "Write cancelled by user".to_string(),
194 session,
195 };
196 }
197 }
198 }
199 }
200}
201
202impl AgentTool for WriteFileTool {
203 type Input = WriteFileToolInput;
204 type Output = EditSessionOutput;
205
206 const NAME: &'static str = "write_file";
207
208 fn supports_input_streaming() -> bool {
209 true
210 }
211
212 fn kind() -> acp::ToolKind {
213 acp::ToolKind::Edit
214 }
215
216 fn initial_title(
217 &self,
218 input: Result<Self::Input, serde_json::Value>,
219 cx: &mut App,
220 ) -> SharedString {
221 match input {
222 Ok(input) => {
223 self.session_context
224 .initial_title_from_path(&input.path, DEFAULT_UI_TEXT, cx)
225 }
226 Err(raw_input) => initial_title_from_partial_path::<WriteFileToolPartialInput>(
227 &self.session_context,
228 raw_input,
229 |partial| partial.path.clone(),
230 DEFAULT_UI_TEXT,
231 cx,
232 ),
233 }
234 }
235
236 fn run(
237 self: Arc<Self>,
238 mut input: ToolInput<Self::Input>,
239 event_stream: ToolCallEventStream,
240 cx: &mut App,
241 ) -> Task<Result<Self::Output, Self::Output>> {
242 cx.spawn(async move |cx: &mut AsyncApp| {
243 run_session(
244 self.process_streaming_writes(&mut input, &event_stream, cx)
245 .await,
246 &event_stream,
247 cx,
248 )
249 .await
250 })
251 }
252
253 fn replay(
254 &self,
255 _input: Self::Input,
256 output: Self::Output,
257 event_stream: ToolCallEventStream,
258 cx: &mut App,
259 ) -> anyhow::Result<()> {
260 self.session_context.replay_output(output, event_stream, cx)
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::{
268 AgentTool, ContextServerRegistry, Templates, Thread, ToolCallEventStream, ToolInput,
269 ToolInputSender,
270 };
271 use acp_thread::Diff;
272 use action_log::ActionLog;
273 use fs::Fs as _;
274 use futures::StreamExt as _;
275 use gpui::{AppContext as _, Entity, TestAppContext, UpdateGlobal};
276 use language::language_settings::FormatOnSave;
277 use language_model::fake_provider::FakeLanguageModel;
278 use project::{Project, ProjectPath};
279 use prompt_store::ProjectContext;
280 use serde_json::json;
281 use settings::{Settings, SettingsStore};
282 use std::{path::PathBuf, sync::Arc};
283 use util::path;
284 use util::rel_path::{RelPath, rel_path};
285
286 #[gpui::test]
287 async fn test_streaming_write_create_file(cx: &mut TestAppContext) {
288 let (write_tool, _project, _action_log, _fs, _thread) =
289 setup_test(cx, json!({"dir": {}})).await;
290 let result = cx
291 .update(|cx| {
292 write_tool.clone().run(
293 ToolInput::resolved(WriteFileToolInput {
294 path: "root/dir/new_file.txt".into(),
295 content: "Hello, World!".into(),
296 }),
297 ToolCallEventStream::test().0,
298 cx,
299 )
300 })
301 .await;
302
303 let EditSessionOutput::Success { new_text, diff, .. } = result.unwrap() else {
304 panic!("expected success");
305 };
306 assert_eq!(new_text, "Hello, World!");
307 assert!(!diff.is_empty());
308 }
309
310 #[gpui::test]
311 async fn test_streaming_write_overwrite_file(cx: &mut TestAppContext) {
312 let (write_tool, _project, _action_log, _fs, _thread) =
313 setup_test(cx, json!({"file.txt": "old content"})).await;
314 let result = cx
315 .update(|cx| {
316 write_tool.clone().run(
317 ToolInput::resolved(WriteFileToolInput {
318 path: "root/file.txt".into(),
319 content: "new content".into(),
320 }),
321 ToolCallEventStream::test().0,
322 cx,
323 )
324 })
325 .await;
326
327 let EditSessionOutput::Success {
328 new_text, old_text, ..
329 } = result.unwrap()
330 else {
331 panic!("expected success");
332 };
333 assert_eq!(new_text, "new content");
334 assert_eq!(*old_text, "old content");
335 }
336
337 #[gpui::test]
338 async fn test_streaming_write_global_skill_file(cx: &mut TestAppContext) {
339 init_test(cx);
340
341 let fs = project::FakeFs::new(cx.executor());
342 fs.insert_tree(path!("/root"), json!({})).await;
343 let skill_dir = agent_skills::global_skills_dir().join("my-skill");
344 fs.insert_tree(&skill_dir, json!({})).await;
345 let (write_tool, _project, _action_log, fs, _thread) =
346 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
347
348 let input_path = PathBuf::from("~")
349 .join(".agents")
350 .join("skills")
351 .join("my-skill")
352 .join("SKILL.md");
353 let skill_file = agent_skills::global_skills_dir()
354 .join("my-skill")
355 .join("SKILL.md");
356
357 let (event_stream, mut event_rx) = ToolCallEventStream::test();
358 let task = cx.update(|cx| {
359 write_tool.clone().run(
360 ToolInput::resolved(WriteFileToolInput {
361 path: input_path,
362 content: "# My Skill\n".into(),
363 }),
364 event_stream,
365 cx,
366 )
367 });
368
369 event_rx.expect_update_fields().await;
370 let auth = event_rx.expect_authorization().await;
371 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
372 assert!(
373 title.contains("agent skills"),
374 "Authorization title should mention agent skills, got: {title}",
375 );
376 assert!(
377 auth.options
378 .first_option_of_kind(acp::PermissionOptionKind::AllowAlways)
379 .is_none(),
380 "agent skills prompt must not offer an \"Always allow\" option: {:?}",
381 auth.options,
382 );
383 auth.response
384 .send(acp_thread::SelectedPermissionOutcome::new(
385 acp::PermissionOptionId::new("allow"),
386 acp::PermissionOptionKind::AllowOnce,
387 ))
388 .expect("authorization response should send");
389
390 let EditSessionOutput::Success { new_text, .. } = task.await.unwrap() else {
391 panic!("expected success");
392 };
393 assert_eq!(new_text, "# My Skill\n");
394 assert_eq!(
395 fs.load(&skill_file).await.unwrap().replace("\r\n", "\n"),
396 "# My Skill\n"
397 );
398 }
399
400 #[gpui::test]
401 async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) {
402 let (write_tool, _project, _action_log, _fs, _thread) =
403 setup_test(cx, json!({"file.txt": "hello world"})).await;
404 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
405 let (event_stream, _receiver) = ToolCallEventStream::test();
406 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
407
408 // Send partial with path but NO mode — path should NOT be treated as complete
409 sender.send_partial(json!({
410 "path": "root/file"
411 }));
412 cx.run_until_parked();
413
414 // Now the path grows and mode appears
415 sender.send_partial(json!({
416 "path": "root/file.txt",
417 }));
418 cx.run_until_parked();
419
420 // Send final
421 sender.send_full(json!({
422 "path": "root/file.txt",
423 "content": "new content"
424 }));
425
426 let result = task.await;
427 let EditSessionOutput::Success { new_text, .. } = result.unwrap() else {
428 panic!("expected success");
429 };
430 assert_eq!(new_text, "new content");
431 }
432
433 #[gpui::test]
434 async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) {
435 let (write_tool, _project, _action_log, _fs, _thread) =
436 setup_test(cx, json!({"dir": {}})).await;
437 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
438 let (event_stream, _receiver) = ToolCallEventStream::test();
439 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
440
441 // Stream partials for create mode
442 sender.send_partial(json!({}));
443 cx.run_until_parked();
444
445 sender.send_partial(json!({
446 "path": "root/dir/new_file.txt",
447 }));
448 cx.run_until_parked();
449
450 sender.send_partial(json!({
451 "path": "root/dir/new_file.txt",
452 "content": "Hello, "
453 }));
454 cx.run_until_parked();
455
456 // Final with full content
457 sender.send_full(json!({
458 "path": "root/dir/new_file.txt",
459 "content": "Hello, World!"
460 }));
461
462 let result = task.await;
463 let EditSessionOutput::Success { new_text, .. } = result.unwrap() else {
464 panic!("expected success");
465 };
466 assert_eq!(new_text, "Hello, World!");
467 }
468
469 #[gpui::test]
470 async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) {
471 let (write_tool, _project, _action_log, _fs, _thread) =
472 setup_test(cx, json!({"dir": {}})).await;
473 // Create a channel and send multiple partials before a final, then use
474 // ToolInput::resolved-style immediate delivery to confirm recv() works
475 // when partials are already buffered.
476 let (mut sender, input): (ToolInputSender, ToolInput<WriteFileToolInput>) =
477 ToolInput::test();
478 let (event_stream, _event_rx) = ToolCallEventStream::test();
479 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
480
481 // Buffer several partials before sending the final
482 sender.send_partial(json!({}));
483 sender.send_partial(json!({"path": "root/dir/new.txt"}));
484 sender.send_partial(json!({
485 "path": "root/dir/new.txt",
486 }));
487 sender.send_full(json!({
488 "path": "root/dir/new.txt",
489 "content": "streamed content"
490 }));
491
492 let result = task.await;
493 let EditSessionOutput::Success { new_text, .. } = result.unwrap() else {
494 panic!("expected success");
495 };
496 assert_eq!(new_text, "streamed content");
497 }
498
499 #[gpui::test]
500 async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) {
501 let mode = EditSessionMode::Write;
502
503 let result = test_resolve_path(&mode, "root/new.txt", cx);
504 assert_resolved_path_eq(result.await, rel_path("new.txt"));
505
506 let result = test_resolve_path(&mode, "new.txt", cx);
507 assert_resolved_path_eq(result.await, rel_path("new.txt"));
508
509 let result = test_resolve_path(&mode, "dir/new.txt", cx);
510 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
511
512 let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx);
513 assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt"));
514
515 let result = test_resolve_path(&mode, "root/dir/subdir", cx);
516 assert_eq!(
517 result.await.unwrap_err(),
518 "Can't write to file: path is a directory"
519 );
520
521 let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx);
522 assert_eq!(
523 result.await.unwrap_err(),
524 "Can't create file: parent directory doesn't exist"
525 );
526 }
527
528 #[gpui::test]
529 async fn test_streaming_format_on_save(cx: &mut TestAppContext) {
530 init_test(cx);
531
532 let fs = project::FakeFs::new(cx.executor());
533 fs.insert_tree("/root", json!({"src": {}})).await;
534 let (write_tool, project, action_log, fs, thread) =
535 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
536
537 let rust_language = Arc::new(language::Language::new(
538 language::LanguageConfig {
539 name: "Rust".into(),
540 matcher: (language::LanguageMatcher {
541 path_suffixes: vec!["rs".to_string()],
542 ..Default::default()
543 })
544 .into(),
545 ..Default::default()
546 },
547 None,
548 ));
549
550 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
551 language_registry.add(rust_language);
552
553 let mut fake_language_servers = language_registry.register_fake_lsp(
554 "Rust",
555 language::FakeLspAdapter {
556 capabilities: lsp::ServerCapabilities {
557 document_formatting_provider: Some(lsp::OneOf::Left(true)),
558 ..Default::default()
559 },
560 ..Default::default()
561 },
562 );
563
564 fs.save(
565 path!("/root/src/main.rs").as_ref(),
566 &"initial content".into(),
567 language::LineEnding::Unix,
568 )
569 .await
570 .unwrap();
571
572 // Open the buffer to trigger LSP initialization
573 let buffer = project
574 .update(cx, |project, cx| {
575 project.open_local_buffer(path!("/root/src/main.rs"), cx)
576 })
577 .await
578 .unwrap();
579
580 // Register the buffer with language servers
581 let _handle = project.update(cx, |project, cx| {
582 project.register_buffer_with_language_servers(&buffer, cx)
583 });
584
585 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\
586";
587 const FORMATTED_CONTENT: &str = "This file was formatted by the fake formatter in the test.\
588";
589
590 // Get the fake language server and set up formatting handler
591 let fake_language_server = fake_language_servers.next().await.unwrap();
592 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
593 |_, _| async move {
594 Ok(Some(vec![lsp::TextEdit {
595 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
596 new_text: FORMATTED_CONTENT.to_string(),
597 }]))
598 }
599 });
600
601 // Test with format_on_save enabled
602 cx.update(|cx| {
603 SettingsStore::update_global(cx, |store, cx| {
604 store.update_user_settings(cx, |settings| {
605 settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
606 settings.project.all_languages.defaults.formatter =
607 Some(language::language_settings::FormatterList::default());
608 });
609 });
610 });
611
612 // Use streaming pattern so executor can pump the LSP request/response
613 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
614 let (event_stream, _receiver) = ToolCallEventStream::test();
615
616 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
617
618 sender.send_partial(json!({
619 "path": "root/src/main.rs",
620 }));
621 cx.run_until_parked();
622
623 sender.send_full(json!({
624 "path": "root/src/main.rs",
625 "content": UNFORMATTED_CONTENT
626 }));
627
628 let result = task.await;
629 assert!(result.is_ok());
630
631 cx.executor().run_until_parked();
632
633 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
634 assert_eq!(
635 new_content.replace("\r\n", "\n"),
636 FORMATTED_CONTENT,
637 "Code should be formatted when format_on_save is enabled"
638 );
639
640 let stale_buffer_count = thread
641 .read_with(cx, |thread, _cx| thread.action_log.clone())
642 .read_with(cx, |log, cx| log.stale_buffers(cx).count());
643
644 assert_eq!(
645 stale_buffer_count, 0,
646 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.",
647 stale_buffer_count
648 );
649
650 // Test with format_on_save disabled
651 cx.update(|cx| {
652 SettingsStore::update_global(cx, |store, cx| {
653 store.update_user_settings(cx, |settings| {
654 settings.project.all_languages.defaults.format_on_save =
655 Some(FormatOnSave::Off);
656 });
657 });
658 });
659
660 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
661 let (event_stream, _receiver) = ToolCallEventStream::test();
662
663 let tool2 = Arc::new(WriteFileTool::new(
664 project.clone(),
665 thread.downgrade(),
666 action_log.clone(),
667 language_registry,
668 ));
669
670 let task = cx.update(|cx| tool2.run(input, event_stream, cx));
671
672 sender.send_partial(json!({
673 "path": "root/src/main.rs",
674 }));
675 cx.run_until_parked();
676
677 sender.send_full(json!({
678 "path": "root/src/main.rs",
679 "content": UNFORMATTED_CONTENT
680 }));
681
682 let result = task.await;
683 assert!(result.is_ok());
684
685 cx.executor().run_until_parked();
686
687 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
688 assert_eq!(
689 new_content.replace("\r\n", "\n"),
690 UNFORMATTED_CONTENT,
691 "Code should not be formatted when format_on_save is disabled"
692 );
693 }
694
695 #[gpui::test]
696 async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) {
697 init_test(cx);
698
699 let fs = project::FakeFs::new(cx.executor());
700 fs.insert_tree("/root", json!({"src": {}})).await;
701 fs.save(
702 path!("/root/src/main.rs").as_ref(),
703 &"initial content".into(),
704 language::LineEnding::Unix,
705 )
706 .await
707 .unwrap();
708 let (write_tool, project, action_log, fs, thread) =
709 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await;
710 let language_registry = project.read_with(cx, |p, _cx| p.languages().clone());
711
712 // Test with remove_trailing_whitespace_on_save enabled
713 cx.update(|cx| {
714 SettingsStore::update_global(cx, |store, cx| {
715 store.update_user_settings(cx, |settings| {
716 settings
717 .project
718 .all_languages
719 .defaults
720 .remove_trailing_whitespace_on_save = Some(true);
721 });
722 });
723 });
724
725 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
726 "fn main() { \n println!(\"Hello!\"); \n}\n";
727
728 let result = cx
729 .update(|cx| {
730 write_tool.clone().run(
731 ToolInput::resolved(WriteFileToolInput {
732 path: "root/src/main.rs".into(),
733 content: CONTENT_WITH_TRAILING_WHITESPACE.into(),
734 }),
735 ToolCallEventStream::test().0,
736 cx,
737 )
738 })
739 .await;
740 assert!(result.is_ok());
741
742 cx.executor().run_until_parked();
743
744 assert_eq!(
745 fs.load(path!("/root/src/main.rs").as_ref())
746 .await
747 .unwrap()
748 .replace("\r\n", "\n"),
749 "fn main() {\n println!(\"Hello!\");\n}\n",
750 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
751 );
752
753 // Test with remove_trailing_whitespace_on_save disabled
754 cx.update(|cx| {
755 SettingsStore::update_global(cx, |store, cx| {
756 store.update_user_settings(cx, |settings| {
757 settings
758 .project
759 .all_languages
760 .defaults
761 .remove_trailing_whitespace_on_save = Some(false);
762 });
763 });
764 });
765
766 let tool2 = Arc::new(WriteFileTool::new(
767 project.clone(),
768 thread.downgrade(),
769 action_log.clone(),
770 language_registry,
771 ));
772
773 let result = cx
774 .update(|cx| {
775 tool2.run(
776 ToolInput::resolved(WriteFileToolInput {
777 path: "root/src/main.rs".into(),
778 content: CONTENT_WITH_TRAILING_WHITESPACE.into(),
779 }),
780 ToolCallEventStream::test().0,
781 cx,
782 )
783 })
784 .await;
785 assert!(result.is_ok());
786
787 cx.executor().run_until_parked();
788
789 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
790 assert_eq!(
791 final_content.replace("\r\n", "\n"),
792 CONTENT_WITH_TRAILING_WHITESPACE,
793 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
794 );
795 }
796
797 #[gpui::test]
798 async fn test_streaming_diff_finalization(cx: &mut TestAppContext) {
799 init_test(cx);
800 let fs = project::FakeFs::new(cx.executor());
801 fs.insert_tree("/", json!({"main.rs": ""})).await;
802 let (write_tool, project, action_log, _fs, thread) =
803 setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await;
804 let language_registry = project.read_with(cx, |p, _cx| p.languages().clone());
805
806 // Ensure the diff is finalized after the edit completes.
807 {
808 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
809 let edit = cx.update(|cx| {
810 write_tool.clone().run(
811 ToolInput::resolved(WriteFileToolInput {
812 path: path!("/main.rs").into(),
813 content: "new content".into(),
814 }),
815 stream_tx,
816 cx,
817 )
818 });
819 stream_rx.expect_update_fields().await;
820 let diff = stream_rx.expect_diff().await;
821 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
822 cx.run_until_parked();
823 edit.await.unwrap();
824 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
825 }
826
827 // Ensure the diff is finalized if the tool call gets dropped.
828 {
829 let tool = Arc::new(WriteFileTool::new(
830 project.clone(),
831 thread.downgrade(),
832 action_log,
833 language_registry,
834 ));
835 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
836 let edit = cx.update(|cx| {
837 tool.run(
838 ToolInput::resolved(WriteFileToolInput {
839 path: path!("/main.rs").into(),
840 content: "dropped content".into(),
841 }),
842 stream_tx,
843 cx,
844 )
845 });
846 stream_rx.expect_update_fields().await;
847 let diff = stream_rx.expect_diff().await;
848 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
849 drop(edit);
850 cx.run_until_parked();
851 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
852 }
853 }
854
855 #[gpui::test]
856 async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) {
857 let (write_tool, project, _action_log, _fs, _thread) =
858 setup_test(cx, json!({"dir": {}})).await;
859 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
860 let (event_stream, _receiver) = ToolCallEventStream::test();
861 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
862
863 // Transition to BufferResolved
864 sender.send_partial(json!({
865 "path": "root/dir/new_file.txt",
866 }));
867 cx.run_until_parked();
868
869 // Stream content incrementally
870 sender.send_partial(json!({
871 "path": "root/dir/new_file.txt",
872 "content": "line 1\n"
873 }));
874 cx.run_until_parked();
875
876 // Verify buffer has partial content
877 let buffer = project.update(cx, |project, cx| {
878 let path = project
879 .find_project_path("root/dir/new_file.txt", cx)
880 .unwrap();
881 project.get_open_buffer(&path, cx).unwrap()
882 });
883 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n");
884
885 // Stream more content
886 sender.send_partial(json!({
887 "path": "root/dir/new_file.txt",
888 "content": "line 1\nline 2\n"
889 }));
890 cx.run_until_parked();
891 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n");
892
893 // Stream final chunk
894 sender.send_partial(json!({
895 "path": "root/dir/new_file.txt",
896 "content": "line 1\nline 2\nline 3\n"
897 }));
898 cx.run_until_parked();
899 assert_eq!(
900 buffer.read_with(cx, |b, _| b.text()),
901 "line 1\nline 2\nline 3\n"
902 );
903
904 // Send final input
905 sender.send_full(json!({
906 "path": "root/dir/new_file.txt",
907 "content": "line 1\nline 2\nline 3\n"
908 }));
909
910 let result = task.await;
911 let EditSessionOutput::Success { new_text, .. } = result.unwrap() else {
912 panic!("expected success");
913 };
914 assert_eq!(new_text, "line 1\nline 2\nline 3\n");
915 }
916
917 #[gpui::test]
918 async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) {
919 let (write_tool, _project, _action_log, _fs, _thread) = setup_test(
920 cx,
921 json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
922 )
923 .await;
924 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
925 let (event_stream, mut receiver) = ToolCallEventStream::test();
926 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
927
928 // Transition to BufferResolved
929 sender.send_partial(json!({
930 "path": "root/file.txt",
931 }));
932 cx.run_until_parked();
933
934 sender.send_partial(json!({
935 "path": "root/file.txt",
936 }));
937 cx.run_until_parked();
938
939 // Get the diff entity from the event stream
940 receiver.expect_update_fields().await;
941 let diff = receiver.expect_diff().await;
942
943 // Diff starts pending with no revealed ranges
944 diff.read_with(cx, |diff, cx| {
945 assert!(matches!(diff, Diff::Pending(_)));
946 assert!(!diff.has_revealed_range(cx));
947 });
948
949 // Stream first content chunk
950 sender.send_partial(json!({
951 "path": "root/file.txt",
952 "content": "new line 1\n"
953 }));
954 cx.run_until_parked();
955
956 // Diff should now have revealed ranges showing the new content
957 diff.read_with(cx, |diff, cx| {
958 assert!(diff.has_revealed_range(cx));
959 });
960
961 // Send final input
962 sender.send_full(json!({
963 "path": "root/file.txt",
964 "content": "new line 1\nnew line 2\n"
965 }));
966
967 let result = task.await;
968 let EditSessionOutput::Success {
969 new_text, old_text, ..
970 } = result.unwrap()
971 else {
972 panic!("expected success");
973 };
974 assert_eq!(new_text, "new line 1\nnew line 2\n");
975 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
976
977 // Diff is finalized after completion
978 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
979 }
980
981 #[gpui::test]
982 async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) {
983 let (write_tool, project, _action_log, _fs, _thread) = setup_test(
984 cx,
985 json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}),
986 )
987 .await;
988 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
989 let (event_stream, _receiver) = ToolCallEventStream::test();
990 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
991
992 // Transition to BufferResolved
993 sender.send_partial(json!({
994 "path": "root/file.txt",
995 }));
996 cx.run_until_parked();
997
998 // Verify buffer still has old content (no content partial yet)
999 let buffer = project.update(cx, |project, cx| {
1000 let path = project.find_project_path("root/file.txt", cx).unwrap();
1001 project.open_buffer(path, cx)
1002 });
1003 let buffer = buffer.await.unwrap();
1004 assert_eq!(
1005 buffer.read_with(cx, |b, _| b.text()),
1006 "old line 1\nold line 2\nold line 3\n"
1007 );
1008
1009 // First content partial replaces old content
1010 sender.send_partial(json!({
1011 "path": "root/file.txt",
1012 "content": "new line 1\n"
1013 }));
1014 cx.run_until_parked();
1015 assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n");
1016
1017 // Subsequent content partials append
1018 sender.send_partial(json!({
1019 "path": "root/file.txt",
1020 "content": "new line 1\nnew line 2\n"
1021 }));
1022 cx.run_until_parked();
1023 assert_eq!(
1024 buffer.read_with(cx, |b, _| b.text()),
1025 "new line 1\nnew line 2\n"
1026 );
1027
1028 // Send final input with complete content
1029 sender.send_full(json!({
1030 "path": "root/file.txt",
1031 "content": "new line 1\nnew line 2\nnew line 3\n"
1032 }));
1033
1034 let result = task.await;
1035 let EditSessionOutput::Success {
1036 new_text, old_text, ..
1037 } = result.unwrap()
1038 else {
1039 panic!("expected success");
1040 };
1041 assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n");
1042 assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n");
1043 }
1044
1045 #[gpui::test]
1046 async fn test_streaming_write_file_tool_registers_changed_buffers(cx: &mut TestAppContext) {
1047 let (write_tool, _project, action_log, _fs, _thread) =
1048 setup_test(cx, json!({"file.txt": "original content"})).await;
1049 cx.update(|cx| {
1050 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1051 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1052 agent_settings::AgentSettings::override_global(settings, cx);
1053 });
1054
1055 let (event_stream, _rx) = ToolCallEventStream::test();
1056 let task = cx.update(|cx| {
1057 write_tool.clone().run(
1058 ToolInput::resolved(WriteFileToolInput {
1059 path: "root/file.txt".into(),
1060 content: "completely new content".into(),
1061 }),
1062 event_stream,
1063 cx,
1064 )
1065 });
1066
1067 let result = task.await;
1068 assert!(result.is_ok(), "write should succeed: {:?}", result.err());
1069
1070 cx.run_until_parked();
1071
1072 let changed =
1073 action_log.read_with(cx, |log, cx| log.changed_buffers(cx).collect::<Vec<_>>());
1074 assert!(
1075 !changed.is_empty(),
1076 "action_log.changed_buffers() should be non-empty after streaming write, \
1077 but no changed buffers were found \u{2014} Accept All / Reject All will not appear"
1078 );
1079 }
1080
1081 #[gpui::test]
1082 async fn test_streaming_write_file_tool_fields_out_of_order(cx: &mut TestAppContext) {
1083 let (write_tool, _project, _action_log, _fs, _thread) =
1084 setup_test(cx, json!({"file.txt": "old_content"})).await;
1085 let (mut sender, input) = ToolInput::<WriteFileToolInput>::test();
1086 let (event_stream, _receiver) = ToolCallEventStream::test();
1087 let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx));
1088
1089 sender.send_partial(json!({
1090 "content": "new_content"
1091 }));
1092 cx.run_until_parked();
1093
1094 sender.send_partial(json!({
1095 "content": "new_content",
1096 "path": "root"
1097 }));
1098 cx.run_until_parked();
1099
1100 // Send final.
1101 sender.send_full(json!({
1102 "content": "new_content",
1103 "path": "root/file.txt"
1104 }));
1105
1106 let result = task.await;
1107 let EditSessionOutput::Success { new_text, .. } = result.unwrap() else {
1108 panic!("expected success");
1109 };
1110 assert_eq!(new_text, "new_content");
1111 }
1112
1113 #[gpui::test]
1114 async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) {
1115 let (write_tool, _project, action_log, fs, _thread) =
1116 setup_test(cx, json!({"dir": {}})).await;
1117 cx.update(|cx| {
1118 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1119 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
1120 agent_settings::AgentSettings::override_global(settings, cx);
1121 });
1122
1123 // Create a new file via the streaming write file tool
1124 let (event_stream, _rx) = ToolCallEventStream::test();
1125 let task = cx.update(|cx| {
1126 write_tool.clone().run(
1127 ToolInput::resolved(WriteFileToolInput {
1128 path: "root/dir/new_file.txt".into(),
1129 content: "Hello, World!".into(),
1130 }),
1131 event_stream,
1132 cx,
1133 )
1134 });
1135 let result = task.await;
1136 assert!(result.is_ok(), "create should succeed: {:?}", result.err());
1137 cx.run_until_parked();
1138
1139 assert!(
1140 fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
1141 "file should exist after creation"
1142 );
1143
1144 // Reject all edits — this should delete the newly created file
1145 let changed =
1146 action_log.read_with(cx, |log, cx| log.changed_buffers(cx).collect::<Vec<_>>());
1147 assert!(
1148 !changed.is_empty(),
1149 "action_log should track the created file as changed"
1150 );
1151
1152 action_log
1153 .update(cx, |log, cx| log.reject_all_edits(None, cx))
1154 .await;
1155 cx.run_until_parked();
1156
1157 assert!(
1158 !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await,
1159 "file should be deleted after rejecting creation, but an empty file was left behind"
1160 );
1161 }
1162
1163 /// When the buffer has unsaved user edits and the user picks
1164 /// "Discard my edits", the pending edits are reverted to match disk
1165 /// and the agent's overwrite proceeds.
1166 #[gpui::test]
1167 async fn test_streaming_write_dirty_buffer_discard(cx: &mut TestAppContext) {
1168 let (write_tool, project, _action_log, fs, _thread) =
1169 setup_test(cx, json!({"file.txt": "on disk content"})).await;
1170
1171 let project_path = project
1172 .read_with(cx, |project, cx| {
1173 project.find_project_path("root/file.txt", cx)
1174 })
1175 .expect("Should find project path");
1176 let buffer = project
1177 .update(cx, |project, cx| project.open_buffer(project_path, cx))
1178 .await
1179 .unwrap();
1180 buffer.update(cx, |buffer, cx| {
1181 let end_point = buffer.max_point();
1182 buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
1183 });
1184 assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
1185
1186 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1187 let task = cx.update(|cx| {
1188 write_tool.clone().run(
1189 ToolInput::resolved(WriteFileToolInput {
1190 path: "root/file.txt".into(),
1191 content: "agent overwrote it".into(),
1192 }),
1193 stream_tx,
1194 cx,
1195 )
1196 });
1197
1198 let _update = stream_rx.expect_update_fields().await;
1199 let auth = stream_rx.expect_authorization().await;
1200
1201 // Verify the prompt is the overwrite-mode prompt.
1202 let content = auth.tool_call.fields.content.as_deref().unwrap_or(&[]);
1203 let acp::ToolCallContent::Content(text) = content.first().expect("expected message body")
1204 else {
1205 panic!("expected text body, got: {:?}", content.first());
1206 };
1207 let acp::ContentBlock::Text(text) = &text.content else {
1208 panic!("expected text body, got: {:?}", text.content);
1209 };
1210 assert!(
1211 text.text.contains("overwrite"),
1212 "expected overwrite-mode prompt, got: {:?}",
1213 text.text,
1214 );
1215
1216 // Verify both option ids are present (option_id is the stable contract).
1217 let option_ids: Vec<&str> = match &auth.options {
1218 acp_thread::PermissionOptions::Flat(opts) => {
1219 opts.iter().map(|o| o.option_id.0.as_ref()).collect()
1220 }
1221 other => panic!("expected flat options, got: {other:?}"),
1222 };
1223 assert!(option_ids.contains(&"keep"), "options: {option_ids:?}");
1224 assert!(option_ids.contains(&"discard"), "options: {option_ids:?}");
1225
1226 auth.response
1227 .send(acp_thread::SelectedPermissionOutcome::new(
1228 acp::PermissionOptionId::new("discard"),
1229 acp::PermissionOptionKind::AllowOnce,
1230 ))
1231 .unwrap();
1232
1233 let EditSessionOutput::Success { new_text, .. } = task.await.unwrap() else {
1234 panic!("expected success");
1235 };
1236 assert_eq!(new_text, "agent overwrote it");
1237 assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
1238 let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap();
1239 assert_eq!(on_disk, "agent overwrote it");
1240 }
1241
1242 /// When the buffer has unsaved user edits and the user picks
1243 /// "Keep my edits", the overwrite is cancelled with an error and the
1244 /// user's pending edits are preserved.
1245 #[gpui::test]
1246 async fn test_streaming_write_dirty_buffer_keep(cx: &mut TestAppContext) {
1247 let (write_tool, project, _action_log, fs, _thread) =
1248 setup_test(cx, json!({"file.txt": "on disk content"})).await;
1249
1250 let project_path = project
1251 .read_with(cx, |project, cx| {
1252 project.find_project_path("root/file.txt", cx)
1253 })
1254 .expect("Should find project path");
1255 let buffer = project
1256 .update(cx, |project, cx| project.open_buffer(project_path, cx))
1257 .await
1258 .unwrap();
1259 buffer.update(cx, |buffer, cx| {
1260 let end_point = buffer.max_point();
1261 buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
1262 });
1263 assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
1264
1265 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1266 let task = cx.update(|cx| {
1267 write_tool.clone().run(
1268 ToolInput::resolved(WriteFileToolInput {
1269 path: "root/file.txt".into(),
1270 content: "agent overwrote it".into(),
1271 }),
1272 stream_tx,
1273 cx,
1274 )
1275 });
1276
1277 let _update = stream_rx.expect_update_fields().await;
1278 let auth = stream_rx.expect_authorization().await;
1279 auth.response
1280 .send(acp_thread::SelectedPermissionOutcome::new(
1281 acp::PermissionOptionId::new("keep"),
1282 acp::PermissionOptionKind::RejectOnce,
1283 ))
1284 .unwrap();
1285
1286 let EditSessionOutput::Error { error, .. } = task.await.unwrap_err() else {
1287 panic!("expected error");
1288 };
1289 assert!(
1290 error.contains("keep") || error.contains("cancelled"),
1291 "expected cancel-style error message, got: {error:?}",
1292 );
1293
1294 // The user's in-memory edits are preserved.
1295 assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
1296 let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text());
1297 assert_eq!(buffer_text, "on disk content plus user edit");
1298
1299 // The on-disk content is untouched.
1300 let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap();
1301 assert_eq!(on_disk, "on disk content");
1302 }
1303
1304 /// When the user manually saves the buffer (e.g. cmd-s) while the
1305 /// overwrite prompt is visible, that's treated as "Keep my edits":
1306 /// the user just deliberately persisted their work, so we cancel the
1307 /// agent's overwrite to avoid clobbering it.
1308 #[gpui::test]
1309 async fn test_streaming_write_dirty_buffer_resolved_externally(cx: &mut TestAppContext) {
1310 let (write_tool, project, _action_log, fs, _thread) =
1311 setup_test(cx, json!({"file.txt": "on disk content"})).await;
1312
1313 let project_path = project
1314 .read_with(cx, |project, cx| {
1315 project.find_project_path("root/file.txt", cx)
1316 })
1317 .expect("Should find project path");
1318 let buffer = project
1319 .update(cx, |project, cx| project.open_buffer(project_path, cx))
1320 .await
1321 .unwrap();
1322 buffer.update(cx, |buffer, cx| {
1323 let end_point = buffer.max_point();
1324 buffer.edit([(end_point..end_point, " plus user edit")], None, cx);
1325 });
1326 assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
1327
1328 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1329 let task = cx.update(|cx| {
1330 write_tool.clone().run(
1331 ToolInput::resolved(WriteFileToolInput {
1332 path: "root/file.txt".into(),
1333 content: "agent overwrote it".into(),
1334 }),
1335 stream_tx,
1336 cx,
1337 )
1338 });
1339
1340 let _update = stream_rx.expect_update_fields().await;
1341 let auth = stream_rx.expect_authorization().await;
1342
1343 // User saves manually while the prompt is up.
1344 project
1345 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1346 .await
1347 .unwrap();
1348
1349 // The prompt is dismissed by resolving the pending authorization.
1350 let (_, outcome) = stream_rx.expect_authorization_resolved().await;
1351 assert_eq!(outcome.option_id, acp::PermissionOptionId::new("keep"));
1352 assert_eq!(outcome.option_kind, acp::PermissionOptionKind::RejectOnce);
1353 drop(auth);
1354
1355 // The overwrite is cancelled with an error.
1356 let EditSessionOutput::Error { error, .. } = task.await.unwrap_err() else {
1357 panic!("expected error");
1358 };
1359 assert!(
1360 error.contains("saved") || error.contains("cancelled"),
1361 "expected cancel-on-manual-save error, got: {error:?}",
1362 );
1363
1364 // The user's edits were saved to disk and not clobbered.
1365 assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
1366 let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap();
1367 assert_eq!(on_disk, "on disk content plus user edit");
1368 }
1369
1370 async fn setup_test_with_fs(
1371 cx: &mut TestAppContext,
1372 fs: Arc<project::FakeFs>,
1373 worktree_paths: &[&std::path::Path],
1374 ) -> (
1375 Arc<WriteFileTool>,
1376 Entity<Project>,
1377 Entity<ActionLog>,
1378 Arc<project::FakeFs>,
1379 Entity<Thread>,
1380 ) {
1381 let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await;
1382 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1383 let context_server_registry =
1384 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1385 let model = Arc::new(FakeLanguageModel::default());
1386 let thread = cx.new(|cx| {
1387 crate::Thread::new(
1388 project.clone(),
1389 cx.new(|_cx| ProjectContext::default()),
1390 context_server_registry,
1391 Templates::new(),
1392 Some(model),
1393 cx,
1394 )
1395 });
1396 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1397 let write_tool = Arc::new(WriteFileTool::new(
1398 project.clone(),
1399 thread.downgrade(),
1400 action_log.clone(),
1401 language_registry,
1402 ));
1403 (write_tool, project, action_log, fs, thread)
1404 }
1405
1406 async fn setup_test(
1407 cx: &mut TestAppContext,
1408 initial_tree: serde_json::Value,
1409 ) -> (
1410 Arc<WriteFileTool>,
1411 Entity<Project>,
1412 Entity<ActionLog>,
1413 Arc<project::FakeFs>,
1414 Entity<Thread>,
1415 ) {
1416 init_test(cx);
1417 let fs = project::FakeFs::new(cx.executor());
1418 fs.insert_tree("/root", initial_tree).await;
1419 setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await
1420 }
1421
1422 async fn test_resolve_path(
1423 mode: &EditSessionMode,
1424 path: &str,
1425 cx: &mut TestAppContext,
1426 ) -> Result<ProjectPath, String> {
1427 init_test(cx);
1428 let fs = project::FakeFs::new(cx.executor());
1429 fs.insert_tree(
1430 "/root",
1431 json!({
1432 "dir": {
1433 "subdir": {
1434 "existing.txt": "content"
1435 }
1436 }
1437 }),
1438 )
1439 .await;
1440 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1441
1442 crate::tools::edit_session::test_resolve_path(mode, path, &project, cx).await
1443 }
1444
1445 #[track_caller]
1446 fn assert_resolved_path_eq(path: Result<ProjectPath, String>, expected: &RelPath) {
1447 let actual = path.expect("Should return valid path").path;
1448 assert_eq!(actual.as_ref(), expected);
1449 }
1450
1451 fn init_test(cx: &mut TestAppContext) {
1452 cx.update(|cx| {
1453 let settings_store = SettingsStore::test(cx);
1454 cx.set_global(settings_store);
1455 SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
1456 store.update_user_settings(cx, |settings| {
1457 settings
1458 .project
1459 .all_languages
1460 .defaults
1461 .ensure_final_newline_on_save = Some(false);
1462 });
1463 });
1464 });
1465 }
1466}
1467