diff --git a/Cargo.lock b/Cargo.lock index 1b70d2680e5e1e15d916511440ea4b73174373aa..69c75d7ce0f9184342fbf202a149c6feb1d6a982 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14035,6 +14035,7 @@ dependencies = [ "paths", "pretty_assertions", "project", + "prompt_store", "proto", "rayon", "release_channel", diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 5cf230629c8e542a23ea7ffc5bdb0fa5a1c73a53..45c09675b2470bc399e7ad38fbf976fb2b06eea6 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -607,6 +607,8 @@ pub struct Thread { pub(crate) prompt_capabilities_rx: watch::Receiver, pub(crate) project: Entity, pub(crate) action_log: Entity, + /// Tracks the last time files were read by the agent, to detect external modifications + pub(crate) file_read_times: HashMap, } impl Thread { @@ -665,6 +667,7 @@ impl Thread { prompt_capabilities_rx, project, action_log, + file_read_times: HashMap::default(), } } @@ -860,6 +863,7 @@ impl Thread { updated_at: db_thread.updated_at, prompt_capabilities_tx, prompt_capabilities_rx, + file_read_times: HashMap::default(), } } @@ -999,6 +1003,7 @@ impl Thread { self.add_tool(NowTool); self.add_tool(OpenTool::new(self.project.clone())); self.add_tool(ReadFileTool::new( + cx.weak_entity(), self.project.clone(), self.action_log.clone(), )); diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index a507044ce51dce5e55c53106c11d8a9b2c2a3d28..de2dd384693c8af3e04007895c843743c5ead722 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -309,6 +309,40 @@ impl AgentTool for EditFileTool { })? .await?; + // Check if the file has been modified since the agent last read it + if let Some(abs_path) = abs_path.as_ref() { + let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| { + let last_read = thread.file_read_times.get(abs_path).copied(); + let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime()); + let dirty = buffer.read(cx).is_dirty(); + (last_read, current, dirty) + })?; + + // Check for unsaved changes first - these indicate modifications we don't know about + if is_dirty { + anyhow::bail!( + "This file cannot be written to because it has unsaved changes. \ + Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \ + Ask the user to save that buffer's changes and to inform you when it's ok to proceed." + ); + } + + // Check if the file was modified on disk since we last read it + if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { + // MTime can be unreliable for comparisons, so our newtype intentionally + // doesn't support comparing them. If the mtime at all different + // (which could be because of a modification or because e.g. system clock changed), + // we pessimistically assume it was modified. + if current != last_read { + anyhow::bail!( + "The file {} has been modified since you last read it. \ + Please read the file again to get the current state before editing it.", + input.path.display() + ); + } + } + } + let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; event_stream.update_diff(diff.clone()); let _finalize_diff = util::defer({ @@ -421,6 +455,17 @@ impl AgentTool for EditFileTool { log.buffer_edited(buffer.clone(), cx); })?; + // Update the recorded read time after a successful edit so consecutive edits work + if let Some(abs_path) = abs_path.as_ref() { + if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { + buffer.file().and_then(|file| file.disk_state().mtime()) + })? { + self.thread.update(cx, |thread, _| { + thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime); + })?; + } + } + let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; let (new_text, unified_diff) = cx .background_spawn({ @@ -1748,10 +1793,426 @@ mod tests { } } + #[gpui::test] + async fn test_file_read_times_tracking(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + // Initially, file_read_times should be empty + let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty()); + assert!(is_empty, "file_read_times should start empty"); + + // Create read tool + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + + // Read the file to record the read time + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Verify that file_read_times now contains an entry for the file + let has_entry = thread.read_with(cx, |thread, _| { + thread.file_read_times.len() == 1 + && thread + .file_read_times + .keys() + .any(|path| path.ends_with("test.txt")) + }); + assert!( + has_entry, + "file_read_times should contain an entry after reading the file" + ); + + // Read the file again - should update the entry + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Should still have exactly one entry + let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1); + assert!( + has_one_entry, + "file_read_times should still have one entry after re-reading" + ); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } + + #[gpui::test] + async fn test_consecutive_edits_work(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + Templates::new(), + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // First edit should work + let edit_result = { + let edit_task = cx.update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "First edit".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }); + + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + "original contentmodified content" + .to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!( + edit_result.is_ok(), + "First edit should succeed, got error: {:?}", + edit_result.as_ref().err() + ); + + // Second edit should also work because the edit updated the recorded read time + let edit_result = { + let edit_task = cx.update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "Second edit".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }); + + cx.executor().run_until_parked(); + model.send_last_completion_stream_text_chunk( + "modified contentfurther modified content".to_string(), + ); + model.end_last_completion_stream(); + + edit_task.await + }; + assert!( + edit_result.is_ok(), + "Second consecutive edit should succeed, got error: {:?}", + edit_result.as_ref().err() + ); + } + + #[gpui::test] + async fn test_external_modification_detected(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + Templates::new(), + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Simulate external modification - advance time and save file + cx.background_executor + .advance_clock(std::time::Duration::from_secs(2)); + fs.save( + path!("/root/test.txt").as_ref(), + &"externally modified content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + // Reload the buffer to pick up the new mtime + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + buffer + .update(cx, |buffer, cx| buffer.reload(cx)) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + // Try to edit - should fail because file was modified externally + let result = cx + .update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "Edit after external change".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + assert!( + result.is_err(), + "Edit should fail after external modification" + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("has been modified since you last read it"), + "Error should mention file modification, got: {}", + error_msg + ); + } + + #[gpui::test] + async fn test_dirty_buffer_detected(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + Templates::new(), + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Open the buffer and make it dirty by editing without saving + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + + // Make an in-memory edit to the buffer (making it dirty) + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " added text")], None, cx); + }); + + // Verify buffer is dirty + let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); + assert!(is_dirty, "Buffer should be dirty after in-memory edit"); + + // Try to edit - should fail because buffer has unsaved changes + let result = cx + .update(|cx| { + edit_tool.clone().run( + EditFileToolInput { + display_description: "Edit with dirty buffer".into(), + path: "root/test.txt".into(), + mode: EditFileMode::Edit, + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + assert!(result.is_err(), "Edit should fail when buffer is dirty"); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("cannot be written to because it has unsaved changes"), + "Error should mention unsaved changes, got: {}", + error_msg + ); + } } diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index 52f88aa4db03a2bc01b0fd10fe99f8bad04c24f1..eccb40737c744d57792655cadb925e18a68d2835 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -1,7 +1,7 @@ use action_log::ActionLog; use agent_client_protocol::{self as acp, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, Entity, SharedString, Task, WeakEntity}; use indoc::formatdoc; use language::Point; use language_model::{LanguageModelImage, LanguageModelToolResultContent}; @@ -12,7 +12,7 @@ use settings::Settings; use std::sync::Arc; use util::markdown::MarkdownCodeBlock; -use crate::{AgentTool, ToolCallEventStream, outline}; +use crate::{AgentTool, Thread, ToolCallEventStream, outline}; /// Reads the content of the given file in the project. /// @@ -42,13 +42,19 @@ pub struct ReadFileToolInput { } pub struct ReadFileTool { + thread: WeakEntity, project: Entity, action_log: Entity, } impl ReadFileTool { - pub fn new(project: Entity, action_log: Entity) -> Self { + pub fn new( + thread: WeakEntity, + project: Entity, + action_log: Entity, + ) -> Self { Self { + thread, project, action_log, } @@ -195,6 +201,17 @@ impl AgentTool for ReadFileTool { anyhow::bail!("{file_path} not found"); } + // Record the file read time and mtime + if let Some(mtime) = buffer.read_with(cx, |buffer, _| { + buffer.file().and_then(|file| file.disk_state().mtime()) + })? { + self.thread + .update(cx, |thread, _| { + thread.file_read_times.insert(abs_path.to_path_buf(), mtime); + }) + .ok(); + } + let mut anchor = None; // Check if specific line ranges are provided @@ -285,11 +302,15 @@ impl AgentTool for ReadFileTool { #[cfg(test)] mod test { use super::*; + use crate::{ContextServerRegistry, Templates, Thread}; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project}; + use prompt_store::ProjectContext; use serde_json::json; use settings::SettingsStore; + use std::sync::Arc; use util::path; #[gpui::test] @@ -300,7 +321,20 @@ mod test { fs.insert_tree(path!("/root"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let (event_stream, _) = ToolCallEventStream::test(); let result = cx @@ -333,7 +367,20 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -363,7 +410,20 @@ mod test { let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(rust_lang())); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -435,7 +495,20 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -463,7 +536,20 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); // start_line of 0 should be treated as 1 let result = cx @@ -607,7 +693,20 @@ mod test { let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project, action_log)); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); // Reading a file outside the project worktree should fail let result = cx @@ -821,7 +920,24 @@ mod test { .await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone())); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log.clone(), + )); // Test reading allowed files in worktree1 let result = cx diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 50e9fd73cb7d1a9b7eeb6b2bf5bf77320fa7a169..e4c7932973741015066efbcd07d0d0c71212acb0 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -94,6 +94,7 @@ project = { workspace = true, features = ["test-support"] } remote = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } +prompt_store.workspace = true unindent.workspace = true serde_json.workspace = true zlog.workspace = true diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 98a0aab70bcb4e5590f477f6e6de9aebd512b3c2..4ceaf2048c5967b7fe1fceeb47c68efc6cc15678 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2,11 +2,12 @@ /// The tests in this file assume that server_cx is running on Windows too. /// We neead to find a way to test Windows-Non-Windows interactions. use crate::headless_project::HeadlessProject; -use agent::{AgentTool, ReadFileTool, ReadFileToolInput, ToolCallEventStream}; +use agent::{AgentTool, ReadFileTool, ReadFileToolInput, Templates, Thread, ToolCallEventStream}; use client::{Client, UserStore}; use clock::FakeSystemClock; use collections::{HashMap, HashSet}; -use language_model::LanguageModelToolResultContent; +use language_model::{LanguageModelToolResultContent, fake_provider::FakeLanguageModel}; +use prompt_store::ProjectContext; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs}; @@ -1722,12 +1723,27 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu let action_log = cx.new(|_| action_log::ActionLog::new(project.clone())); + // Create a minimal thread for the ReadFileTool + let context_server_registry = + cx.new(|cx| agent::ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let input = ReadFileToolInput { path: "project/b.txt".into(), start_line: None, end_line: None, }; - let read_tool = Arc::new(ReadFileTool::new(project, action_log)); + let read_tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let (event_stream, _) = ToolCallEventStream::test(); let exists_result = cx.update(|cx| read_tool.clone().run(input, event_stream.clone(), cx));