use std::sync::Arc; use forge_domain::{Agent, ContextMessage, Conversation, Role, TextMessage}; use forge_template::Element; use crate::utils::format_display_path; use crate::{EnvironmentInfra, FsReadService}; /// Service responsible for detecting externally changed files and rendering /// notifications pub struct ChangedFiles { services: Arc, agent: Agent, } impl ChangedFiles { /// Creates a new ChangedFiles pub fn new(services: Arc, agent: Agent) -> Self { Self { services, agent } } } impl> ChangedFiles { /// Detects externally changed files and renders a notification if changes /// are found. Updates file hashes in conversation metrics to prevent /// duplicate notifications. pub async fn update_file_stats(&self, mut conversation: Conversation) -> Conversation { use crate::file_tracking::FileChangeDetector; let parallel_file_reads = self .services .get_config() .map(|c| c.max_parallel_file_reads) .unwrap_or(4); let changes = FileChangeDetector::new(self.services.clone()) .detect(&conversation.metrics, parallel_file_reads) .await; if changes.is_empty() { return conversation; } // Update file hashes to prevent duplicate notifications let mut updated_metrics = conversation.metrics.clone(); for change in &changes { if let Some(path_str) = change.path.to_str() && let Some(metrics) = updated_metrics.file_operations.get_mut(path_str) { // Update the file hash metrics.content_hash = change.content_hash.clone(); } } conversation.metrics = updated_metrics; let cwd = self.services.get_environment().cwd; let file_elements: Vec = changes .iter() .map(|change| { let display_path = format_display_path(&change.path, &cwd); Element::new("file").text(display_path) }) .collect(); let notification = Element::new("information") .append( Element::new("critical") .text("The following files have been modified externally. Please re-read them if its relevant for the task."), ) .append(Element::new("files").append(file_elements)) .to_string(); let context = conversation.context.take().unwrap_or_default(); let message = TextMessage::new(Role::User, notification) .droppable(true) .model(self.agent.model.clone()); conversation = conversation.context(context.add_message(ContextMessage::from(message))); conversation } } #[cfg(test)] mod tests { use std::collections::HashMap; use std::path::PathBuf; use forge_domain::{ Agent, AgentId, Context, Conversation, ConversationId, Environment, FileOperation, Metrics, ModelId, ProviderId, ToolKind, }; use pretty_assertions::assert_eq; use super::*; use crate::services::Content; use crate::{FsReadService, ReadOutput, compute_hash}; #[derive(Clone, Default)] struct TestServices { files: HashMap, cwd: Option, } #[async_trait::async_trait] impl FsReadService for TestServices { async fn read( &self, path: String, _: Option, _: Option, ) -> anyhow::Result { self.files .get(&path) .map(|content| { let hash = compute_hash(content); ReadOutput { content: Content::file(content.clone()), info: forge_domain::FileInfo::new(1, 1, 1, hash), } }) .ok_or_else(|| anyhow::anyhow!(std::io::Error::from(std::io::ErrorKind::NotFound))) } } impl EnvironmentInfra for TestServices { type Config = forge_config::ForgeConfig; fn get_environment(&self) -> Environment { use fake::{Fake, Faker}; let mut env: Environment = Faker.fake(); if let Some(cwd) = &self.cwd { env.cwd = cwd.clone(); } else { // Use a deterministic cwd that won't match any test paths env.cwd = PathBuf::from("/deterministic/test/cwd"); } env } fn get_config(&self) -> anyhow::Result { Ok(forge_config::ForgeConfig { max_parallel_file_reads: 4, ..Default::default() }) } async fn update_environment( &self, _ops: Vec, ) -> anyhow::Result<()> { unimplemented!() } fn get_env_var(&self, _key: &str) -> Option { None } fn get_env_vars(&self) -> std::collections::BTreeMap { std::collections::BTreeMap::new() } } fn fixture( files: HashMap, tracked_files: HashMap>, ) -> (ChangedFiles, Conversation) { fixture_with_cwd(files, tracked_files, None) } fn fixture_with_cwd( files: HashMap, tracked_files: HashMap>, cwd: Option, ) -> (ChangedFiles, Conversation) { let services = Arc::new(TestServices { files, cwd }); let agent = Agent::new( AgentId::new("test"), ProviderId::ANTHROPIC, ModelId::new("test-model"), ); let changed_files = ChangedFiles::new(services, agent); let mut metrics = Metrics::default(); for (path, hash) in tracked_files { metrics .file_operations .insert(path, FileOperation::new(ToolKind::Write).content_hash(hash)); } let conversation = Conversation::new(ConversationId::generate()).metrics(metrics); (changed_files, conversation) } #[tokio::test] async fn test_no_changes_detected() { let content = "hello world"; let hash = crate::compute_hash(content); let (service, mut conversation) = fixture( [("/test/file.txt".into(), content.into())].into(), [("/test/file.txt".into(), Some(hash))].into(), ); conversation.context = Some(Context::default().add_message(ContextMessage::user( "Hey, there!", Some(ModelId::new("test")), ))); let actual = service.update_file_stats(conversation.clone()).await; assert_eq!(actual.context.clone().unwrap_or_default().messages.len(), 1); assert_eq!(actual.context, conversation.context); } #[tokio::test] async fn test_changes_detected_adds_notification() { let old_hash = crate::compute_hash("old content"); let new_content = "new content"; let (service, conversation) = fixture( [("/test/file.txt".into(), new_content.into())].into(), [("/test/file.txt".into(), Some(old_hash))].into(), ); let actual = service.update_file_stats(conversation).await; let messages = &actual.context.unwrap().messages; assert_eq!(messages.len(), 1); let message = messages[0].content().unwrap().to_string(); assert!(message.contains("/test/file.txt")); assert!(message.contains("modified externally")); } #[tokio::test] async fn test_updates_content_hash() { let old_hash = crate::compute_hash("old content"); let new_content = "new content"; let new_hash = crate::compute_hash(new_content); let (service, conversation) = fixture( [("/test/file.txt".into(), new_content.into())].into(), [("/test/file.txt".into(), Some(old_hash))].into(), ); let actual = service.update_file_stats(conversation).await; let updated_hash = actual .metrics .file_operations .get("/test/file.txt") .and_then(|m| m.content_hash.clone()); assert_eq!(updated_hash, Some(new_hash)); } #[tokio::test] async fn test_multiple_files_changed() { let (service, conversation) = fixture( [ ("/test/file1.txt".into(), "new 1".into()), ("/test/file2.txt".into(), "new 2".into()), ] .into(), [ ("/test/file1.txt".into(), Some(crate::compute_hash("old 1"))), ("/test/file2.txt".into(), Some(crate::compute_hash("old 2"))), ] .into(), ); let actual = service.update_file_stats(conversation).await; let message = actual.context.unwrap().messages[0] .content() .unwrap() .to_string(); insta::assert_snapshot!(message); } #[tokio::test] async fn test_uses_relative_paths_within_cwd() { let old_hash = crate::compute_hash("old content"); let new_content = "new content"; let cwd = PathBuf::from("/home/user/project"); let absolute_path = "/home/user/project/src/main.rs"; let (service, conversation) = fixture_with_cwd( [(absolute_path.into(), new_content.into())].into(), [(absolute_path.into(), Some(old_hash))].into(), Some(cwd), ); let actual = service.update_file_stats(conversation).await; let message = actual.context.unwrap().messages[0] .content() .unwrap() .to_string(); let expected = "\nThe following files have been modified externally. Please re-read them if its relevant for the task.\n\nsrc/main.rs\n\n"; assert_eq!(message, expected); } }