| use std::sync::Arc; |
|
|
| use anyhow::Result; |
| use forge_domain::*; |
| use schemars::JsonSchema; |
| use serde::Deserialize; |
|
|
| use crate::{ |
| AppConfigService, EnvironmentInfra, FileDiscoveryService, ProviderService, TemplateEngine, |
| TerminalContextService, |
| }; |
|
|
| |
| #[derive(Debug, Clone, Deserialize, JsonSchema)] |
| #[serde(rename_all = "snake_case")] |
| #[schemars(title = "shell_command")] |
| pub struct ShellCommandResponse { |
| |
| pub command: String, |
| } |
|
|
| |
| pub struct CommandGenerator<S> { |
| services: Arc<S>, |
| } |
|
|
| impl<S> CommandGenerator<S> |
| where |
| S: EnvironmentInfra<Config = forge_config::ForgeConfig> |
| + FileDiscoveryService |
| + ProviderService |
| + AppConfigService, |
| { |
| |
| pub fn new(services: Arc<S>) -> Self { |
| Self { services } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| pub async fn generate(&self, prompt: UserPrompt) -> Result<String> { |
| |
| let env = self.services.get_environment(); |
|
|
| let files = self.services.list_current_directory().await?; |
|
|
| let rendered_system_prompt = TemplateEngine::default().render( |
| "forge-command-generator-prompt.md", |
| &serde_json::json!({"env": env, "files": files}), |
| )?; |
|
|
| |
| |
| let (provider, model) = match self.services.get_suggest_config().await? { |
| Some(config) => { |
| let provider = self.services.get_provider(config.provider).await?; |
| (provider, config.model) |
| } |
| None => { |
| let model_config = self |
| .services |
| .get_session_config() |
| .await |
| .ok_or_else(|| forge_domain::Error::NoDefaultSession)?; |
| let provider = self.services.get_provider(model_config.provider).await?; |
| (provider, model_config.model) |
| } |
| }; |
|
|
| |
| use forge_template::Element; |
| let task_elm = Element::new("task").text(prompt.as_str()); |
| let terminal_service = TerminalContextService::new(self.services.clone()); |
| let user_content = match terminal_service.get_terminal_context() { |
| Some(ctx) => { |
| let terminal_elm = |
| Element::new("command_trace").append(ctx.commands.iter().map(|cmd| { |
| Element::new("command") |
| .attr("exit_code", cmd.exit_code.to_string()) |
| .text(&cmd.command) |
| })); |
| format!("{}\n\n{}", terminal_elm.render(), task_elm.render()) |
| } |
| None => task_elm.render(), |
| }; |
|
|
| |
| let ctx = self.create_context(rendered_system_prompt, user_content, &model); |
|
|
| |
| let stream = self.services.chat(&model, ctx, provider).await?; |
| let message = stream.into_full(false).await?; |
|
|
| |
| let response: ShellCommandResponse = |
| serde_json::from_str(&message.content).map_err(|e| { |
| anyhow::anyhow!( |
| "Failed to parse shell command response: {}. Response: {}", |
| e, |
| message.content |
| ) |
| })?; |
|
|
| Ok(response.command) |
| } |
|
|
| |
| fn create_context( |
| &self, |
| system_prompt: String, |
| user_content: String, |
| model: &ModelId, |
| ) -> Context { |
| |
| let schema = schemars::schema_for!(ShellCommandResponse); |
|
|
| Context::default() |
| .add_message(ContextMessage::system(system_prompt)) |
| .add_message(ContextMessage::user(user_content, Some(model.clone()))) |
| .response_format(ResponseFormat::JsonSchema(Box::new(schema))) |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use forge_domain::{ |
| AuthCredential, AuthDetails, AuthMethod, ChatCompletionMessage, Content, FinishReason, |
| ModelSource, ProviderId, ProviderResponse, ResultStream, Role, |
| }; |
| use tokio::sync::Mutex; |
| use url::Url; |
|
|
| use super::*; |
| use crate::Walker; |
|
|
| struct MockServices { |
| files: Vec<(String, bool)>, |
| response: Arc<Mutex<Option<String>>>, |
| captured_context: Arc<Mutex<Option<Context>>>, |
| environment: Environment, |
| env_vars: std::collections::BTreeMap<String, String>, |
| } |
|
|
| impl MockServices { |
| fn new(response: &str, files: Vec<(&str, bool)>) -> Arc<Self> { |
| use fake::{Fake, Faker}; |
| let mut env: Environment = Faker.fake(); |
| |
| env.os = "macos".to_string(); |
| env.cwd = "/test/dir".into(); |
| env.shell = "/bin/bash".to_string(); |
| env.home = Some("/home/test".into()); |
|
|
| Arc::new(Self { |
| files: files.into_iter().map(|(p, d)| (p.to_string(), d)).collect(), |
| response: Arc::new(Mutex::new(Some(response.to_string()))), |
| captured_context: Arc::new(Mutex::new(None)), |
| environment: env, |
| env_vars: std::collections::BTreeMap::new(), |
| }) |
| } |
|
|
| fn with_terminal_context( |
| self: Arc<Self>, |
| commands: &str, |
| exit_codes: &str, |
| timestamps: &str, |
| ) -> Arc<Self> { |
| let mut env_vars = self.env_vars.clone(); |
| env_vars.insert("_FORGE_TERM_COMMANDS".to_string(), commands.to_string()); |
| env_vars.insert("_FORGE_TERM_EXIT_CODES".to_string(), exit_codes.to_string()); |
| env_vars.insert("_FORGE_TERM_TIMESTAMPS".to_string(), timestamps.to_string()); |
| Arc::new(Self { |
| files: self.files.clone(), |
| response: self.response.clone(), |
| captured_context: self.captured_context.clone(), |
| environment: self.environment.clone(), |
| env_vars, |
| }) |
| } |
| } |
|
|
| impl EnvironmentInfra for MockServices { |
| type Config = forge_config::ForgeConfig; |
|
|
| fn get_environment(&self) -> Environment { |
| self.environment.clone() |
| } |
|
|
| fn get_config(&self) -> anyhow::Result<forge_config::ForgeConfig> { |
| Ok(forge_config::ForgeConfig::default()) |
| } |
|
|
| async fn update_environment( |
| &self, |
| _ops: Vec<forge_domain::ConfigOperation>, |
| ) -> anyhow::Result<()> { |
| unimplemented!() |
| } |
|
|
| fn get_env_var(&self, key: &str) -> Option<String> { |
| self.env_vars.get(key).cloned() |
| } |
|
|
| fn get_env_vars(&self) -> std::collections::BTreeMap<String, String> { |
| self.env_vars.clone() |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl FileDiscoveryService for MockServices { |
| async fn collect_files(&self, _walker: Walker) -> Result<Vec<File>> { |
| Ok(self |
| .files |
| .iter() |
| .map(|(path, is_dir)| File { path: path.clone(), is_dir: *is_dir }) |
| .collect()) |
| } |
|
|
| async fn list_current_directory(&self) -> Result<Vec<File>> { |
| let mut files: Vec<File> = self |
| .files |
| .iter() |
| .map(|(path, is_dir)| File { path: path.clone(), is_dir: *is_dir }) |
| .collect(); |
|
|
| |
| files.sort_by(|a, b| match (a.is_dir, b.is_dir) { |
| (true, false) => std::cmp::Ordering::Less, |
| (false, true) => std::cmp::Ordering::Greater, |
| _ => a.path.cmp(&b.path), |
| }); |
|
|
| Ok(files) |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl ProviderService for MockServices { |
| async fn chat( |
| &self, |
| _id: &ModelId, |
| context: Context, |
| _provider: Provider<Url>, |
| ) -> ResultStream<ChatCompletionMessage, anyhow::Error> { |
| *self.captured_context.lock().await = Some(context); |
|
|
| let response = self.response.lock().await.take().unwrap(); |
| let message = ChatCompletionMessage::assistant(Content::full(response)) |
| .finish_reason(FinishReason::Stop); |
| Ok(Box::pin(tokio_stream::iter(std::iter::once(Ok(message))))) |
| } |
|
|
| async fn models(&self, _provider: Provider<Url>) -> Result<Vec<forge_domain::Model>> { |
| Ok(vec![]) |
| } |
|
|
| async fn get_provider(&self, _id: ProviderId) -> Result<Provider<Url>> { |
| Ok(Provider { |
| id: ProviderId::OPENAI, |
| provider_type: Default::default(), |
| response: Some(ProviderResponse::OpenAI), |
| url: Url::parse("https://api.test.com").unwrap(), |
| models: Some(ModelSource::Url( |
| Url::parse("https://api.test.com/models").unwrap(), |
| )), |
| auth_methods: vec![AuthMethod::ApiKey], |
| url_params: vec![], |
| credential: Some(AuthCredential { |
| id: ProviderId::OPENAI, |
| auth_details: AuthDetails::ApiKey("test-key".to_string().into()), |
| url_params: Default::default(), |
| }), |
| custom_headers: None, |
| }) |
| } |
|
|
| async fn get_all_providers(&self) -> Result<Vec<forge_domain::AnyProvider>> { |
| Ok(vec![]) |
| } |
|
|
| async fn upsert_credential(&self, _credential: AuthCredential) -> Result<()> { |
| Ok(()) |
| } |
|
|
| async fn remove_credential(&self, _id: &ProviderId) -> Result<()> { |
| Ok(()) |
| } |
|
|
| async fn migrate_env_credentials(&self) -> anyhow::Result<Option<MigrationResult>> { |
| Ok(None) |
| } |
| } |
|
|
| #[async_trait::async_trait] |
| impl AppConfigService for MockServices { |
| async fn get_session_config(&self) -> Option<forge_domain::ModelConfig> { |
| Some(forge_domain::ModelConfig::new( |
| ProviderId::OPENAI, |
| ModelId::new("test-model"), |
| )) |
| } |
|
|
| async fn get_commit_config(&self) -> Result<Option<forge_domain::ModelConfig>> { |
| Ok(None) |
| } |
|
|
| async fn get_suggest_config(&self) -> Result<Option<forge_domain::ModelConfig>> { |
| Ok(None) |
| } |
|
|
| async fn get_reasoning_effort(&self) -> Result<Option<forge_domain::Effort>> { |
| Ok(None) |
| } |
|
|
| async fn update_config(&self, _ops: Vec<forge_domain::ConfigOperation>) -> Result<()> { |
| Ok(()) |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_generate_simple_command() { |
| let fixture = MockServices::new( |
| r#"{"command": "ls -la"}"#, |
| vec![("file1.txt", false), ("file2.rs", false)], |
| ); |
| let generator = CommandGenerator::new(fixture.clone()); |
|
|
| let actual = generator |
| .generate(UserPrompt::from("list all files".to_string())) |
| .await |
| .unwrap(); |
|
|
| assert_eq!(actual, "ls -la"); |
| let captured_context = fixture.captured_context.lock().await.clone().unwrap(); |
| insta::assert_yaml_snapshot!(captured_context); |
| } |
|
|
| #[tokio::test] |
| async fn test_generate_with_no_files() { |
| let fixture = MockServices::new(r#"{"command": "pwd"}"#, vec![]); |
| let generator = CommandGenerator::new(fixture.clone()); |
|
|
| let actual = generator |
| .generate(UserPrompt::from("show current directory".to_string())) |
| .await |
| .unwrap(); |
|
|
| assert_eq!(actual, "pwd"); |
| let captured_context = fixture.captured_context.lock().await.clone().unwrap(); |
| insta::assert_yaml_snapshot!(captured_context); |
| } |
|
|
| #[tokio::test] |
| async fn test_generate_with_shell_context() { |
| let fixture = MockServices::new( |
| r#"{"command": "cargo build --release"}"#, |
| vec![("Cargo.toml", false)], |
| ) |
| .with_terminal_context("cargo build", "101", "1700000000"); |
| let generator = CommandGenerator::new(fixture.clone()); |
|
|
| let actual = generator |
| .generate(UserPrompt::from("fix the command I just ran".to_string())) |
| .await |
| .unwrap(); |
|
|
| assert_eq!(actual, "cargo build --release"); |
| let captured_context = fixture.captured_context.lock().await.clone().unwrap(); |
| let user_content = captured_context |
| .messages |
| .iter() |
| .find(|m| m.has_role(Role::User)) |
| .expect("should have a user message") |
| .content() |
| .expect("user message should have content"); |
| assert!(user_content.contains("<command_trace>")); |
| assert!(user_content.contains("</command_trace>")); |
| assert!(user_content.contains("cargo build")); |
| assert!(user_content.contains("<task>fix the command I just ran</task>")); |
| } |
|
|
| #[tokio::test] |
| async fn test_generate_fails_when_missing_tag() { |
| let fixture = MockServices::new(r#"{"invalid": "json"}"#, vec![]); |
| let generator = CommandGenerator::new(fixture); |
|
|
| let actual = generator |
| .generate(UserPrompt::from("do something".to_string())) |
| .await; |
|
|
| assert!(actual.is_err()); |
| let error_msg = actual.unwrap_err().to_string(); |
| assert!(error_msg.contains("Failed to parse shell command response")); |
| } |
| } |
|
|