From 5a88b40912708a2b01f55311a789111b480f51df Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 15:35:26 -0700 Subject: [PATCH 01/26] refactor(utils): Remove dead code and structs that are no longer used --- Cargo.lock | 1 - crates/acp-utils/src/config_meta.rs | 8 +- crates/aether-core/src/core/prompt.rs | 6 +- crates/aether-project/src/mcp_source_spec.rs | 6 +- crates/aether-project/src/prompt_file.rs | 17 +- .../tests/integration/plugins_mcp_skills.rs | 36 ---- crates/utils/Cargo.toml | 1 - crates/utils/src/display_meta.rs | 63 ------- crates/utils/src/lib.rs | 4 - crates/utils/src/markdown_file.rs | 175 ------------------ crates/utils/src/serde_helpers.rs | 8 - 11 files changed, 19 insertions(+), 306 deletions(-) delete mode 100644 crates/utils/src/markdown_file.rs delete mode 100644 crates/utils/src/serde_helpers.rs diff --git a/Cargo.lock b/Cargo.lock index 7bca17d4f..29a152188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,7 +446,6 @@ name = "aether-utils" version = "0.2.19" dependencies = [ "futures", - "noyalib", "regex", "schemars 1.2.2", "serde", diff --git a/crates/acp-utils/src/config_meta.rs b/crates/acp-utils/src/config_meta.rs index 3559d990c..c84b89ec0 100644 --- a/crates/acp-utils/src/config_meta.rs +++ b/crates/acp-utils/src/config_meta.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use utils::{ReasoningEffort, is_false}; +use utils::ReasoningEffort; use crate::meta::{from_meta, to_meta}; @@ -8,7 +8,7 @@ type Meta = serde_json::Map; /// Meta for a top-level `SessionConfigOption` (e.g. the "model" config). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ConfigOptionMeta { - #[serde(default, skip_serializing_if = "is_false")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub multi_select: bool, } @@ -17,9 +17,9 @@ pub struct ConfigOptionMeta { pub struct SelectOptionMeta { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reasoning_levels: Vec, - #[serde(default, skip_serializing_if = "is_false")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub supports_image: bool, - #[serde(default, skip_serializing_if = "is_false")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub supports_audio: bool, } diff --git a/crates/aether-core/src/core/prompt.rs b/crates/aether-core/src/core/prompt.rs index 4e02194b8..acac4377c 100644 --- a/crates/aether-core/src/core/prompt.rs +++ b/crates/aether-core/src/core/prompt.rs @@ -11,7 +11,7 @@ use tracing::warn; use utils::shell_expander::ShellExpander; use utils::substitution::substitute_parameters; use utils::variables::VarError; -use utils::{PathOrObject, ResourcePath, is_false, string_or_object_schema}; +use utils::{PathOrObject, ResourcePath, string_or_object_schema}; #[derive(Debug, Clone, PartialEq)] pub enum Prompt { @@ -143,14 +143,14 @@ enum PromptSourceObject { /// Path to a prompt file, resolved as a resource path. path: ResourcePath, /// When true, a missing file is skipped instead of raising an error. - #[serde(default, skip_serializing_if = "is_false")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] optional: bool, }, Glob { /// Glob pattern matching prompt files, resolved as a resource path. pattern: ResourcePath, /// When true, a zero-match glob is skipped instead of raising an error. - #[serde(default, skip_serializing_if = "is_false")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] optional: bool, }, } diff --git a/crates/aether-project/src/mcp_source_spec.rs b/crates/aether-project/src/mcp_source_spec.rs index 8afdfb48c..6fcdb70bb 100644 --- a/crates/aether-project/src/mcp_source_spec.rs +++ b/crates/aether-project/src/mcp_source_spec.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use utils::variables::VarError; -use utils::{PathOrObject, ResourcePath, is_false, string_or_object_schema}; +use utils::{PathOrObject, ResourcePath, string_or_object_schema}; #[derive(Debug, Clone, PartialEq)] pub enum McpSourceSpec { @@ -145,9 +145,9 @@ impl schemars::JsonSchema for McpSourceSpec { enum McpSourceSpecObject { File { path: ResourcePath, - #[serde(rename = "deferTools", default, skip_serializing_if = "is_false")] + #[serde(rename = "deferTools", default, skip_serializing_if = "std::ops::Not::not")] defer_tools: bool, - #[serde(default, skip_serializing_if = "is_false")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] optional: bool, }, Inline { diff --git a/crates/aether-project/src/prompt_file.rs b/crates/aether-project/src/prompt_file.rs index fceff0163..189ddad15 100644 --- a/crates/aether-project/src/prompt_file.rs +++ b/crates/aether-project/src/prompt_file.rs @@ -28,7 +28,7 @@ pub(crate) struct PromptFrontmatter { /// Cursor compatibility: top-level path patterns (alias for `triggers.read`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paths: Vec, - #[serde(default, skip_serializing_if = "not")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub agent_authored: bool, #[serde(default, skip_serializing_if = "zero")] pub helpful: u32, @@ -166,8 +166,7 @@ impl PromptFile { /// Parse YAML frontmatter and body from a SKILL.md content string (no I/O). fn parse_frontmatter(content: &str) -> Result<(PromptFrontmatter, String), PromptFileError> { - let (yaml_str, body) = - utils::markdown_file::split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?; + let (yaml_str, body) = split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?; let frontmatter: PromptFrontmatter = noyalib::from_str(yaml_str)?; @@ -237,17 +236,19 @@ pub enum PromptFileError { NotAgentAuthored(String), } +fn split_frontmatter(content: &str) -> Option<(&str, &str)> { + let content = content.trim(); + let rest = content.strip_prefix("---")?; + let end_pos = rest.find("\n---")?; + Some((&rest[..end_pos], rest[end_pos + 4..].trim())) +} + fn normalize_frontmatter_yaml(yaml: &str) -> &str { let yaml = yaml.trim(); let yaml = yaml.strip_prefix("---\n").unwrap_or(yaml); yaml.strip_suffix("\n...").unwrap_or(yaml).trim() } -#[expect(clippy::trivially_copy_pass_by_ref)] -fn not(b: &bool) -> bool { - !b -} - #[expect(clippy::trivially_copy_pass_by_ref)] fn zero(n: &u32) -> bool { *n == 0 diff --git a/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs b/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs index 7c3b92ed4..3d6fe635a 100644 --- a/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs +++ b/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs @@ -1,42 +1,6 @@ use crate::common::{McpClientExt, TestResult, connect, create_test_files, load_skills_input, skills_server}; use mcp_servers::skills::tools::ListSkillsInput; use mcp_utils::client::ClientOptions; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use utils::MarkdownFile; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TestFrontmatter { - pub description: Option, -} - -#[tokio::test] -async fn test_load_from_nested_directories() { - let test_files = vec![ - ("skill-1/SKILL.md", "---\ndescription: First skill\n---\nThis is skill 1 content"), - ("skill-2/SKILL.md", "---\ndescription: Second skill\n---\nThis is skill 2 content"), - ("illegal-flat-skill.md", "This should be ignored"), - ]; - - let temp_dir = create_test_files(&test_files); - - let skills_with_dirs: Vec<(PathBuf, MarkdownFile)> = - MarkdownFile::from_nested_dirs(temp_dir.path(), "SKILL.md").await.expect("Failed to load skills"); - - assert_eq!(skills_with_dirs.len(), 2); - - let skill_names: Vec = skills_with_dirs - .iter() - .filter_map(|(dir, _)| { - let name = dir.file_name()?.to_string_lossy().to_string(); - Some(name) - }) - .collect(); - - assert!(skill_names.contains(&"skill-1".to_string())); - assert!(skill_names.contains(&"skill-2".to_string())); - assert!(!skill_names.contains(&"illegal-flat-skill".to_string())); -} #[tokio::test] async fn test_load_skills_tool() -> TestResult { diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 1040628d9..40a3d8e4c 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -29,7 +29,6 @@ regex = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -noyalib = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "macros"] } tracing = { workspace = true } diff --git a/crates/utils/src/display_meta.rs b/crates/utils/src/display_meta.rs index 15a81f8a0..27a546228 100644 --- a/crates/utils/src/display_meta.rs +++ b/crates/utils/src/display_meta.rs @@ -1,5 +1,3 @@ -use std::path::Path; - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -80,23 +78,6 @@ impl ToolResultMeta { } } -pub fn extension_hint(path: &str) -> String { - Path::new(path).extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase() -} - -impl ToolResultMeta { - pub fn into_map(self) -> serde_json::Map { - match serde_json::to_value(self).expect("ToolResultMeta should serialize") { - serde_json::Value::Object(map) => map, - _ => unreachable!("ToolResultMeta should serialize to a JSON object"), - } - } - - pub fn from_map(map: &serde_json::Map) -> Option { - serde_json::from_value(serde_json::Value::Object(map.clone())).ok() - } -} - pub fn truncate(s: &str, max_length: usize) -> String { if s.chars().count() <= max_length { s.to_string() @@ -131,12 +112,6 @@ mod tests { assert_eq!(&parsed, val); } - fn assert_map_roundtrip(meta: &ToolResultMeta) { - let map = meta.clone().into_map(); - let parsed = ToolResultMeta::from_map(&map).expect("should deserialize"); - assert_eq!(&parsed, meta); - } - fn sample_diff(old_text: Option<&str>) -> FileDiff { FileDiff { path: "/tmp/main.rs".to_string(), @@ -179,35 +154,6 @@ mod tests { assert_serde_roundtrip(&result_meta); } - #[test] - fn test_tool_result_meta_map_roundtrips() { - let plain: ToolResultMeta = display("Read file", "Cargo.toml, 156 lines").into(); - assert_map_roundtrip(&plain); - - let with_diff = ToolResultMeta::with_file_diff(display("Edit file", "main.rs"), sample_diff(Some("old"))); - assert_map_roundtrip(&with_diff); - - let with_plan = ToolResultMeta::with_plan( - display("Todo", "Research AI agents"), - PlanMeta { - entries: vec![PlanMetaEntry { - content: "Research AI agents".into(), - status: PlanMetaStatus::InProgress, - }], - }, - ); - assert_map_roundtrip(&with_plan); - } - - #[test] - fn test_tool_result_meta_from_invalid_map_returns_none() { - let map = serde_json::Map::from_iter([( - "display".to_string(), - serde_json::Value::String("not an object".to_string()), - )]); - assert!(ToolResultMeta::from_map(&map).is_none()); - } - #[test] fn test_into_result_meta() { let d = display("Write file", "main.rs"); @@ -248,15 +194,6 @@ mod tests { assert_eq!(parsed.old_text, None); } - #[test] - fn test_extension_hint() { - for (path, expected) in - [("/path/to/main.rs", "rs"), ("README.MD", "md"), ("Makefile", ""), ("/foo/bar/baz.tsx", "tsx")] - { - assert_eq!(extension_hint(path), expected, "path: {path}"); - } - } - #[test] fn test_truncate() { assert_eq!(truncate("short", 10), "short"); diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 724932d53..68d572f52 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -2,7 +2,6 @@ pub mod artifact_review; pub mod display_meta; -pub mod markdown_file; pub mod mcp_status; pub mod name_pattern; pub mod panic; @@ -10,7 +9,6 @@ pub mod path; pub mod reasoning; pub mod resource_path; pub mod schema_document; -pub mod serde_helpers; pub mod settings; #[cfg(not(target_family = "wasm"))] pub mod shell_expander; @@ -21,10 +19,8 @@ pub mod temp_dir; pub mod tool_result_truncator; pub mod variables; -pub use markdown_file::MarkdownFile; pub use name_pattern::matches_name_pattern; pub use path::home_relative_path; pub use reasoning::ReasoningEffort; pub use resource_path::{PathOrObject, ResourcePath, string_or_object_schema}; -pub use serde_helpers::is_false; pub use settings::SettingsStore; diff --git a/crates/utils/src/markdown_file.rs b/crates/utils/src/markdown_file.rs deleted file mode 100644 index 860c946be..000000000 --- a/crates/utils/src/markdown_file.rs +++ /dev/null @@ -1,175 +0,0 @@ -use futures::future::join_all; -use serde::de::DeserializeOwned; -use std::{ - fs, io, - path::{Path, PathBuf}, -}; - -/// Represents a parsed markdown file with optional frontmatter -#[derive(Debug, Clone)] -pub struct MarkdownFile { - /// Parsed frontmatter (if present) - pub frontmatter: Option, - /// The content after frontmatter - pub content: String, -} - -impl MarkdownFile { - pub fn parse(path: impl AsRef) -> Result { - let raw_content = fs::read_to_string(path)?; - - match split_frontmatter(&raw_content) { - Some((yaml_str, body)) => { - let frontmatter = noyalib::from_str(yaml_str).ok(); - Ok(Self { frontmatter, content: body.to_string() }) - } - None => Ok(Self { frontmatter: None, content: raw_content.trim().to_string() }), - } - } - - /// List all markdown files in a directory - pub fn list(dir: impl AsRef) -> Result, io::Error> { - let paths: Vec<_> = fs::read_dir(dir)? - .filter_map(|entry| { - let path = entry.ok()?.path(); - (path.extension().and_then(|s| s.to_str()) == Some("md")).then_some(path) - }) - .collect(); - - Ok(paths) - } - - /// Load a single markdown file from a path - pub fn from_file(path: impl AsRef) -> Result { - let path = path.as_ref(); - - if !path.exists() { - return Err(ParseError::Io(io::Error::new( - io::ErrorKind::NotFound, - format!("File not found: {}", path.display()), - ))); - } - - Self::parse(path) - } - - /// Load all markdown files from a directory - pub async fn from_dir(dir: &PathBuf) -> Result, io::Error> { - if !dir.exists() { - return Err(io::Error::new(io::ErrorKind::NotFound, format!("Directory not found: {}", dir.display()))); - } - - if !dir.is_dir() { - return Err(io::Error::new(io::ErrorKind::NotADirectory, format!("Not a directory: {}", dir.display()))); - } - - let parse_tasks: Vec<_> = Self::list(dir)? - .into_iter() - .map(|path| { - tokio::spawn(async move { - let path_clone = path.clone(); - Self::parse(path).map(|f| (path_clone, f)) - }) - }) - .collect(); - - let results = join_all(parse_tasks).await; - let items = results - .into_iter() - .filter_map(|result| { - result.ok().and_then(|r| r.inspect_err(|e| tracing::warn!("Failed to parse file: {}", e)).ok()) - }) - .collect(); - - Ok(items) - } - - /// Load all markdown files from nested subdirectories, where each subdirectory - /// contains a file with the specified filename. - /// - /// Flat files in the parent directory are ignored. Only subdirectories containing - /// the specified filename are processed. - /// - /// # Example - /// ```ignore - /// // Load from: - /// // skills/skill-1/SKILL.md - /// // skills/skill-2/SKILL.md - /// // skills/flat-file.md -> ignored (not in a subdirectory) - /// let skills = MarkdownFile::from_nested_dirs(Path::new("skills"), "SKILL.md").await?; - /// ``` - pub async fn from_nested_dirs( - parent_dir: impl AsRef, - filename: &str, - ) -> Result, io::Error> { - let parent_dir = parent_dir.as_ref(); - - if !parent_dir.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("Directory not found: {}", parent_dir.display()), - )); - } - - if !parent_dir.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotADirectory, - format!("Not a directory: {}", parent_dir.display()), - )); - } - - let subdirs = list_subdirs(parent_dir)?; - let filename = filename.to_string(); - let parse_tasks: Vec<_> = subdirs - .into_iter() - .map(|dir| { - let filename = filename.clone(); - tokio::spawn(async move { - let file_path = dir.join(&filename); - Self::parse(&file_path).map(|f| (dir, f)) - }) - }) - .collect(); - - let results = join_all(parse_tasks).await; - let items = results - .into_iter() - .filter_map(|result| { - result.ok().and_then(|r| r.inspect_err(|e| tracing::debug!("Skipping directory: {}", e)).ok()) - }) - .collect(); - - Ok(items) - } -} - -/// Split YAML frontmatter from markdown content. -/// -/// Returns `(yaml_str, body)` if frontmatter delimiters (`---`) are found, -/// or `None` if the content has no frontmatter. -pub fn split_frontmatter(content: &str) -> Option<(&str, &str)> { - let content = content.trim(); - let rest = content.strip_prefix("---")?; - let end_pos = rest.find("\n---")?; - Some((&rest[..end_pos], rest[end_pos + 4..].trim())) -} - -/// List all subdirectories in a directory -fn list_subdirs(dir: impl AsRef) -> Result, io::Error> { - let paths: Vec<_> = fs::read_dir(dir)? - .filter_map(|entry| { - let path = entry.ok()?.path(); - path.is_dir().then_some(path) - }) - .collect(); - - Ok(paths) -} - -#[derive(Debug, thiserror::Error)] -pub enum ParseError { - #[error("Invalid filename")] - InvalidFilename, - #[error("IO error: {0}")] - Io(#[from] io::Error), -} diff --git a/crates/utils/src/serde_helpers.rs b/crates/utils/src/serde_helpers.rs deleted file mode 100644 index 7aeccc724..000000000 --- a/crates/utils/src/serde_helpers.rs +++ /dev/null @@ -1,8 +0,0 @@ -/// Serde `skip_serializing_if` helper for `bool` fields that default to `false`. -/// -/// Takes `&bool` rather than `bool` because `skip_serializing_if` calls the -/// predicate with a reference to the field. -#[allow(clippy::trivially_copy_pass_by_ref)] -pub fn is_false(b: &bool) -> bool { - !*b -} From aff36acdb5790bc009e503cccf4ccffdc9fbd470 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 15:42:48 -0700 Subject: [PATCH 02/26] refactor(mcp-servers): remove dead subagent, task and skills code Delete code with no callers outside its own definition and self-tests: StructuredAgentOutput/ArtifactReference and extract_json_from_markdown, the task archive/remove path (archive_tree, remove_tree, remove, all_tasks), and the never-constructed TaskStoreError variants InvalidParent and ValidationError. Collapse the three hand-built SubAgentResult literals into SubAgentResult::new. Drop the deprecated, ignored skills --notes-dir flag. Callers still passing it will now fail argument parsing. --- crates/mcp-servers/src/skills/server.rs | 15 +- crates/mcp-servers/src/subagents/tools/mod.rs | 3 +- .../src/subagents/tools/spawn_subagent/mod.rs | 209 ++---------------- crates/mcp-servers/src/tasks/task_index.rs | 62 ------ crates/mcp-servers/src/tasks/task_store.rs | 54 ----- .../tests/integration/stdio_transport.rs | 15 -- 6 files changed, 17 insertions(+), 341 deletions(-) diff --git a/crates/mcp-servers/src/skills/server.rs b/crates/mcp-servers/src/skills/server.rs index fe5f7f246..383571fea 100644 --- a/crates/mcp-servers/src/skills/server.rs +++ b/crates/mcp-servers/src/skills/server.rs @@ -37,10 +37,6 @@ pub struct SkillsMcpArgs { /// On skill name collision, the last directory wins. #[arg(long = "dir", required = true)] pub dirs: Vec, - - /// Deprecated: Accepted for backward compatibility but ignored. - #[arg(long = "notes-dir")] - pub notes_dir: Option, } #[doc = include_str!("../docs/skills_mcp.md")] @@ -361,7 +357,7 @@ mod tests { #[test] fn skills_args_require_at_least_one_dir() { - let parsed = SkillsMcpArgs::try_parse_from(["--notes-dir", ".aether/notes"]); + let parsed = SkillsMcpArgs::try_parse_from(Vec::<&str>::new()); assert!(parsed.is_err()); } @@ -372,15 +368,6 @@ mod tests { assert_eq!(parsed.dirs, vec![PathBuf::from(".aether/skills"), PathBuf::from(".claude/rules")]); } - #[test] - fn skills_args_accepts_deprecated_notes_dir() { - let parsed = - SkillsMcpArgs::try_parse_from(["--dir", ".aether/skills", "--notes-dir", ".aether/notes"]).unwrap(); - - assert_eq!(parsed.dirs, vec![PathBuf::from(".aether/skills")]); - assert_eq!(parsed.notes_dir, Some(PathBuf::from(".aether/notes"))); - } - #[test] fn from_args_roots_server_at_base_dir() { let workspace = PathBuf::from("/workspace"); diff --git a/crates/mcp-servers/src/subagents/tools/mod.rs b/crates/mcp-servers/src/subagents/tools/mod.rs index f532dbb80..5910d02a5 100644 --- a/crates/mcp-servers/src/subagents/tools/mod.rs +++ b/crates/mcp-servers/src/subagents/tools/mod.rs @@ -1,6 +1,5 @@ pub mod spawn_subagent; pub use spawn_subagent::{ - AgentExecutor, ArtifactReference, SpawnSubAgentsInput, SpawnSubAgentsOutput, StructuredAgentOutput, SubAgentResult, - SubAgentStatus, SubAgentTask, extract_json_from_markdown, + AgentExecutor, SpawnSubAgentsInput, SpawnSubAgentsOutput, SubAgentResult, SubAgentStatus, SubAgentTask, }; diff --git a/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs b/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs index 84786f095..2ed9afbd2 100644 --- a/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs +++ b/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs @@ -20,59 +20,6 @@ use tokio::time::timeout; use utils::display_meta::{ToolDisplayMeta, ToolResultMeta}; use utils::panic::panic_message; -/// Reference to a file artifact discovered or modified by a sub-agent -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct ArtifactReference { - /// Absolute file path - pub path: String, - /// How this file relates to the task: read, modified, discovered, relevant - pub relation: String, - /// Brief note on why this file matters - pub note: Option, -} - -/// Structured output from a sub-agent, designed to prevent information loss -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct StructuredAgentOutput { - /// Brief summary of what the agent accomplished - pub summary: String, - /// Files read, modified, or discovered as relevant - pub artifacts: Vec, - /// Key decisions, findings, or conclusions - pub decisions: Vec, - /// Recommended follow-up tasks - pub next_steps: Vec, - /// Full detailed output (optional, for deep dives) - pub details: Option, -} - -impl StructuredAgentOutput { - /// Parse agent output into structured format with fallback for non-JSON responses - pub fn parse(raw_output: &str) -> Result { - // Try to parse as JSON directly - if let Ok(parsed) = serde_json::from_str::(raw_output) { - return Ok(parsed); - } - - if let Some(json_str) = extract_json_from_markdown(raw_output) - && let Ok(parsed) = serde_json::from_str::(&json_str) - { - return Ok(parsed); - } - - // Fallback: wrap raw output in structured format - Ok(Self { - summary: "Agent did not return structured output".to_string(), - artifacts: vec![], - decisions: vec![], - next_steps: vec![], - details: Some(raw_output.to_string()), - }) - } -} - /// Input for a single agent task within a batch #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -119,6 +66,16 @@ pub struct SubAgentResult { pub error: Option, } +impl SubAgentResult { + fn new(task_id: String, agent_name: String, result: Result) -> Self { + let (status, output, error) = match result { + Ok(output) => (SubAgentStatus::Success, Some(output), None), + Err(error) => (SubAgentStatus::Error, None, Some(error)), + }; + Self { task_id, agent_name, status, output, error } + } +} + /// Output from spawning sub-agents #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -138,24 +95,6 @@ pub struct SpawnSubAgentsOutput { /// Prompt instructions appended to sub-agent prompts to ensure structured output pub const STRUCTURED_OUTPUT_INSTRUCTIONS: &str = include_str!("structured_output_instructions.md"); -/// Extract JSON from markdown code blocks (```json ... ``` or ``` ... ```) -pub fn extract_json_from_markdown(text: &str) -> Option { - // Look for ```json ... ``` pattern - let json_block_start = text.find("```json")?; - let content_start = json_block_start + 7; - let remaining = &text[content_start..]; - - // Find the closing ``` - let content_end = remaining.find("```")?; - let json_content = remaining[..content_end].trim(); - - if json_content.is_empty() { - return None; - } - - Some(json_content.to_string()) -} - /// Executor for spawning and running sub-agents #[derive(Clone)] pub struct AgentExecutor { @@ -195,12 +134,9 @@ impl AgentExecutor { let result = AssertUnwindSafe(executor.execute_single(task_id.clone(), task)) .catch_unwind() .await - .unwrap_or_else(|panic| SubAgentResult { - task_id, - agent_name, - status: SubAgentStatus::Error, - output: None, - error: Some(format!("Sub-agent task panicked: {}", panic_message(&*panic))), + .unwrap_or_else(|panic| { + let error = format!("Sub-agent task panicked: {}", panic_message(&*panic)); + SubAgentResult::new(task_id, agent_name, Err(error)) }); (index, result) }); @@ -214,8 +150,7 @@ impl AgentExecutor { let results: Vec = indexed_results.into_iter().map(|(_, result)| result).collect(); let success_count = results.iter().filter(|r| matches!(r.status, SubAgentStatus::Success)).count(); - - let error_count = results.iter().filter(|r| matches!(r.status, SubAgentStatus::Error)).count(); + let error_count = results.len() - success_count; // Create display metadata using the first task let display_meta = ToolDisplayMeta::new("Spawn agent", format!("{first_agent_name} (1/{task_count})")); @@ -271,22 +206,7 @@ impl AgentExecutor { } .await; - match result { - Ok(output) => SubAgentResult { - task_id, - agent_name, - status: SubAgentStatus::Success, - output: Some(output), - error: None, - }, - Err(error) => SubAgentResult { - task_id, - agent_name, - status: SubAgentStatus::Error, - output: None, - error: Some(error.to_string()), - }, - } + SubAgentResult::new(task_id, agent_name, result.map_err(|error| error.to_string())) } async fn report(&self, task_id: &str, agent_name: &str, event: &AgentEvent) { @@ -345,105 +265,6 @@ const MCP_READY_TIMEOUT: Duration = Duration::from_secs(30); mod tests { use super::*; - #[test] - fn test_parse_valid_json() { - let json = r#"{ - "summary": "Found the main entry point", - "artifacts": [{"path": "/src/main.rs", "relation": "read", "note": "entry point"}], - "decisions": ["Use async runtime"], - "nextSteps": ["Implement feature"], - "details": null - }"#; - - let result = StructuredAgentOutput::parse(json).expect("Should parse valid JSON"); - - assert_eq!(result.summary, "Found the main entry point"); - assert_eq!(result.artifacts.len(), 1); - assert_eq!(result.artifacts[0].path, "/src/main.rs"); - assert_eq!(result.artifacts[0].relation, "read"); - assert_eq!(result.decisions.len(), 1); - assert_eq!(result.next_steps.len(), 1); - } - - #[test] - fn test_parse_json_in_markdown() { - let markdown = r#"Here's what I found: - -```json -{ - "summary": "Analyzed the codebase", - "artifacts": [], - "decisions": ["Use Rust"], - "nextSteps": [], - "details": null -} -``` - -Hope this helps!"#; - - let result = StructuredAgentOutput::parse(markdown).expect("Should parse JSON from markdown"); - - assert_eq!(result.summary, "Analyzed the codebase"); - assert_eq!(result.decisions.len(), 1); - assert_eq!(result.decisions[0], "Use Rust"); - } - - #[test] - fn test_parse_fallback() { - let plain_text = "I analyzed the code and found several issues."; - - let result = StructuredAgentOutput::parse(plain_text).expect("Should fallback gracefully"); - - assert_eq!(result.summary, "Agent did not return structured output"); - assert!(result.artifacts.is_empty()); - assert_eq!(result.decisions.len(), 0); - assert_eq!(result.next_steps.len(), 0); - assert_eq!(result.details, Some(plain_text.to_string())); - } - - #[test] - fn test_extract_json_from_markdown_valid() { - let markdown = r#"Some text -```json -{"key": "value"} -``` -More text"#; - - let result = extract_json_from_markdown(markdown); - - assert_eq!(result, Some(r#"{"key": "value"}"#.to_string())); - } - - #[test] - fn test_extract_json_from_markdown_no_block() { - let text = "Just plain text without code blocks"; - - let result = extract_json_from_markdown(text); - - assert!(result.is_none()); - } - - #[test] - fn test_extract_json_from_markdown_empty_block() { - let markdown = r"```json -```"; - - let result = extract_json_from_markdown(markdown); - - assert!(result.is_none()); - } - - #[test] - fn test_extract_json_from_markdown_unclosed() { - let markdown = r#"```json -{"unclosed": true} -"#; - - let result = extract_json_from_markdown(markdown); - - assert!(result.is_none()); - } - #[test] fn test_sub_agent_task_accepts_snake_case_agent_name() { let task: SubAgentTask = serde_json::from_value(serde_json::json!({ diff --git a/crates/mcp-servers/src/tasks/task_index.rs b/crates/mcp-servers/src/tasks/task_index.rs index 27148e1ff..285373d53 100644 --- a/crates/mcp-servers/src/tasks/task_index.rs +++ b/crates/mcp-servers/src/tasks/task_index.rs @@ -82,49 +82,6 @@ impl TaskIndex { self.tasks.insert(id, task); } - /// Remove a task from the index - pub fn remove(&mut self, id: &TaskId) -> Option { - if let Some(task) = self.tasks.remove(id) { - if let Some(assignee) = &task.assignee - && let Some(ids) = self.by_assignee.get_mut(assignee) - { - ids.remove(id); - if ids.is_empty() { - self.by_assignee.remove(assignee); - } - } - - if let Some(ids) = self.by_status.get_mut(&task.status) { - ids.remove(id); - if ids.is_empty() { - self.by_status.remove(&task.status); - } - } - - Some(task) - } else { - None - } - } - - /// Remove an entire task tree from the index - pub fn remove_tree(&mut self, root_id: &TaskId) -> Vec { - let mut removed = Vec::new(); - - let ids_to_remove: Vec = self.tasks.keys().filter(|id| id.root() == *root_id).cloned().collect(); - - for id in ids_to_remove { - if let Some(task) = self.remove(&id) { - removed.push(task); - } - } - - self.trees.remove(root_id); - self.subtask_counters.remove(root_id); - - removed - } - /// Get a task by ID pub fn get(&self, id: &TaskId) -> Option<&Task> { self.tasks.get(id) @@ -181,11 +138,6 @@ impl TaskIndex { self.trees.keys().collect() } - /// Get all tasks (for iteration) - pub fn all_tasks(&self) -> impl Iterator { - self.tasks.values() - } - /// Check if a task exists pub fn contains(&self, id: &TaskId) -> bool { self.tasks.contains_key(id) @@ -316,20 +268,6 @@ mod tests { assert_eq!(tree.len(), 3); } - #[test] - fn test_remove_tree() { - let mut index = TaskIndex::new(); - let path = PathBuf::from(".aether-tasks/active/at-abc123.jsonl"); - - index.insert(make_task("at-abc123", TaskStatus::Pending, None), path.clone()); - index.insert(make_task("at-abc123.1", TaskStatus::Pending, None), path.clone()); - index.insert(make_task("at-abc123.2", TaskStatus::Pending, None), path); - - let removed = index.remove_tree(&TaskId::from("at-abc123")); - assert_eq!(removed.len(), 3); - assert!(index.is_empty()); - } - #[test] fn test_get_ready() { let mut index = TaskIndex::new(); diff --git a/crates/mcp-servers/src/tasks/task_store.rs b/crates/mcp-servers/src/tasks/task_store.rs index 1cb634345..d9c217400 100644 --- a/crates/mcp-servers/src/tasks/task_store.rs +++ b/crates/mcp-servers/src/tasks/task_store.rs @@ -23,12 +23,6 @@ pub enum TaskStoreError { #[error("Dependency not found: {id}")] DependencyNotFound { id: String }, - #[error("Cannot create subtask of non-root task: {id}")] - InvalidParent { id: String }, - - #[error("Validation error: {message}")] - ValidationError { message: String }, - #[error("IO error: {0}")] Io(#[from] std::io::Error), @@ -179,29 +173,6 @@ impl TaskStore { self.index.get_ready() } - /// Archive a completed task tree (move to completed directory) - pub fn archive_tree(&mut self, root_id: &TaskId) -> Result<(), TaskStoreError> { - let root_id = root_id.root(); - - let src_path = self - .index - .get_tree_path(&root_id) - .cloned() - .ok_or_else(|| TaskStoreError::TreeNotFound { id: root_id.to_string() })?; - - let completed_dir = self.completed_dir(); - if !completed_dir.exists() { - fs::create_dir_all(&completed_dir)?; - } - - let dst_path = completed_dir.join(root_id.filename()); - - fs::rename(&src_path, &dst_path)?; - self.index.remove_tree(&root_id); - - Ok(()) - } - /// Get all root task IDs pub fn list_trees(&self) -> Vec<&TaskId> { self.index.root_ids() @@ -221,10 +192,6 @@ impl TaskStore { self.root.join("active") } - fn completed_dir(&self) -> PathBuf { - self.root.join("completed") - } - fn generate_hash(&self) -> String { use std::time::{SystemTime, UNIX_EPOCH}; @@ -405,27 +372,6 @@ mod tests { assert!(ready_ids.contains(&subtask2.id.as_str())); } - #[test] - fn test_archive_tree() { - let mut tasks = TestTaskStore::new(); - - let root = tasks.root_task("Completed research"); - tasks.subtask(&root.id, "Subtask"); - - let store = tasks.store_mut(); - let active_file = store.active_dir().join(root.id.filename()); - assert!(active_file.exists()); - - store.archive_tree(&root.id).unwrap(); - - assert!(!active_file.exists()); - - let completed_file = store.completed_dir().join(root.id.filename()); - assert!(completed_file.exists()); - - assert!(store.is_empty()); - } - #[test] fn test_persistence() { let mut tasks = TestTaskStore::new(); diff --git a/crates/mcp-servers/tests/integration/stdio_transport.rs b/crates/mcp-servers/tests/integration/stdio_transport.rs index cdb3924cf..af4c59123 100644 --- a/crates/mcp-servers/tests/integration/stdio_transport.rs +++ b/crates/mcp-servers/tests/integration/stdio_transport.rs @@ -104,21 +104,6 @@ async fn skills_server_lists_tools_over_stdio() { assert!(names.contains(&"get_skills"), "expected get_skills, got: {names:?}"); } -#[tokio::test] -async fn skills_server_accepts_deprecated_notes_dir_over_stdio() { - let tmp = tempfile::tempdir().expect("create temp dir"); - - let tools = connect_and_list_tools( - "skills", - &["--", "--dir", tmp.path().to_str().unwrap(), "--notes-dir", tmp.path().to_str().unwrap()], - ) - .await; - let names = tool_names(&tools); - - assert!(names.contains(&"list_skills"), "expected list_skills, got: {names:?}"); - assert!(names.contains(&"get_skills"), "expected get_skills, got: {names:?}"); -} - #[tokio::test] async fn subagents_server_lists_tools_over_stdio() { let tmp = tempfile::tempdir().expect("create temp dir"); From 9be968122bbff39b5d2086c9e79ad4c9c3652ffc Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 15:50:09 -0700 Subject: [PATCH 03/26] refactor(mcp-servers): remove dead coding-tool and lsp code Delete code with no callers outside its own definition and self-tests: unused web search/fetch constructors and fake-client helpers, the panicking WebSearcher::default, never-constructed WebFetchError, LspError and FileError variants, the LspError Result alias, unused LspRegistry/DiagnosticCounts/WorkspacePaths/PromptRuleMatcher methods, and the file_io re-export module. Drop the never-read skip_serializing fields ReadFileResult.raw_content and EditFileResponse.content, which also removes a full-file clone per edit. --- crates/mcp-servers/src/coding/error.rs | 8 -- .../src/coding/prompt_rule_matcher.rs | 21 ---- .../src/coding/tools/edit_file/mod.rs | 6 +- .../mcp-servers/src/coding/tools/file_io.rs | 1 - crates/mcp-servers/src/coding/tools/mod.rs | 1 - .../src/coding/tools/read_file/mod.rs | 4 - .../src/coding/tools/web_fetch/mod.rs | 52 --------- .../src/coding/tools/web_search/mod.rs | 10 -- .../coding/tools/web_search/search_client.rs | 110 ++---------------- crates/mcp-servers/src/docs/lsp_error.md | 5 - crates/mcp-servers/src/docs/lsp_registry.md | 1 - .../src/docs/prompt_rule_matcher.md | 2 +- crates/mcp-servers/src/file_ops.rs | 3 - crates/mcp-servers/src/lsp/diagnostics.rs | 18 --- crates/mcp-servers/src/lsp/error.rs | 10 -- crates/mcp-servers/src/lsp/registry.rs | 23 ---- crates/mcp-servers/src/testing/web_fetch.rs | 36 +----- crates/mcp-servers/src/workspace_paths.rs | 24 ---- 18 files changed, 14 insertions(+), 321 deletions(-) delete mode 100644 crates/mcp-servers/src/coding/tools/file_io.rs diff --git a/crates/mcp-servers/src/coding/error.rs b/crates/mcp-servers/src/coding/error.rs index fe5cd4b30..df45f26a6 100644 --- a/crates/mcp-servers/src/coding/error.rs +++ b/crates/mcp-servers/src/coding/error.rs @@ -96,14 +96,6 @@ pub enum WebFetchError { /// Request timed out #[error("Request timed out after {0}ms")] Timeout(u64), - - /// Response too large - #[error("Response too large: {size} bytes exceeds limit of {limit} bytes")] - ResponseTooLarge { size: usize, limit: usize }, - - /// Failed to parse HTML content - #[error("Failed to parse HTML: {0}")] - ParseFailed(String), } /// Errors related to web search operations diff --git a/crates/mcp-servers/src/coding/prompt_rule_matcher.rs b/crates/mcp-servers/src/coding/prompt_rule_matcher.rs index 29a43c53a..f1cbd34e7 100644 --- a/crates/mcp-servers/src/coding/prompt_rule_matcher.rs +++ b/crates/mcp-servers/src/coding/prompt_rule_matcher.rs @@ -31,11 +31,6 @@ impl PromptRuleMatcher { result } - - /// Allow previously activated rules to fire again after a context reset. - pub fn clear(&mut self) { - self.activated.clear(); - } } impl Default for PromptRuleMatcher { @@ -82,22 +77,6 @@ mod tests { assert!(matched.is_empty()); } - #[test] - fn clear_activated_allows_rematching() { - let workspace = workspace_with_rust_rules("Rust rules."); - let catalog = PromptCatalog::from_dir(workspace.root()).unwrap(); - let mut state = PromptRuleMatcher::new(catalog); - let root_dir = Path::new("/project"); - - let matched = state.get_matched_rules(root_dir, "/project/src/main.rs"); - assert_eq!(matched.len(), 1); - - state.clear(); - - let matched2 = state.get_matched_rules(root_dir, "/project/src/main.rs"); - assert_eq!(matched2.len(), 1); - } - #[test] fn from_catalog_builds_rules() { let workspace = workspace_with_rust_rules("Follow Rust best practices.") diff --git a/crates/mcp-servers/src/coding/tools/edit_file/mod.rs b/crates/mcp-servers/src/coding/tools/edit_file/mod.rs index 650228e45..69409a3ef 100644 --- a/crates/mcp-servers/src/coding/tools/edit_file/mod.rs +++ b/crates/mcp-servers/src/coding/tools/edit_file/mod.rs @@ -24,9 +24,6 @@ pub struct EditFileResponse { pub total_lines: usize, /// Number of replacements made pub replacements_made: usize, - /// The new file content after editing (used internally for LSP sync) - #[serde(skip_serializing)] - pub content: String, /// Display metadata for human-friendly rendering #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] #[schemars(skip)] @@ -40,14 +37,13 @@ pub async fn edit_file_contents(args: EditFileArgs) -> Result, pub size: usize, - /// Raw file content without line numbers (used internally for LSP sync) - #[serde(skip_serializing)] - pub raw_content: String, /// Display metadata for human-friendly rendering #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] #[schemars(skip)] @@ -80,7 +77,6 @@ pub async fn read_file_contents(args: ReadFileArgs) -> Result Option { #[cfg(test)] mod tests { use super::*; - use crate::testing::FakeHttpClient; - - fn input(url: &str) -> WebFetchInput { - WebFetchInput { url: url.to_string(), prompt: None, timeout: None } - } #[test] fn test_normalize_url() { @@ -201,51 +196,4 @@ mod tests { assert_eq!(result.title, Some("Sparse".to_string())); assert!(result.markdown.contains("Hi")); } - - #[tokio::test] - async fn test_fake_client_returns_configured_response() { - let fake = FakeHttpClient::new().with_html( - "https://example.com/", - "Example

Hello

", - ); - let fetcher = WebFetcher::with_client(fake.clone()); - let result = fetcher.fetch(input("example.com")).await.unwrap(); - - assert_eq!(result.status_code, 200); - assert_eq!(result.title, Some("Example".to_string())); - assert!(result.content.contains("Hello")); - assert_eq!(fake.fetch_count(), 1); - } - - #[tokio::test] - async fn test_fake_client_tracks_fetch_history() { - let fake = FakeHttpClient::new() - .with_html("https://example.com/page1", "

Page 1

") - .with_html("https://example.com/page2", "

Page 2

"); - let fetcher = WebFetcher::with_client(fake.clone()); - - fetcher.fetch(input("https://example.com/page1")).await.unwrap(); - fetcher.fetch(input("https://example.com/page2")).await.unwrap(); - - assert_eq!(fake.fetch_count(), 2); - assert_eq!(fake.fetched_urls(), vec!["https://example.com/page1", "https://example.com/page2"]); - } - - #[tokio::test] - async fn test_fake_client_missing_url_returns_error() { - let fetcher = WebFetcher::with_client(FakeHttpClient::new()); - assert!(fetcher.fetch(input("https://not-configured.com/")).await.is_err()); - } - - #[tokio::test] - async fn test_fake_client_with_default_response() { - let fake = FakeHttpClient::new().with_default(HttpResponse { - final_url: "https://fallback.com/".to_string(), - status_code: 404, - body: "

Not Found

".to_string(), - content_type: Some("text/html".to_string()), - }); - let result = WebFetcher::with_client(fake).fetch(input("https://any-url.com/")).await.unwrap(); - assert_eq!(result.status_code, 404); - } } diff --git a/crates/mcp-servers/src/coding/tools/web_search/mod.rs b/crates/mcp-servers/src/coding/tools/web_search/mod.rs index dcf66dd38..c07ecf5d9 100644 --- a/crates/mcp-servers/src/coding/tools/web_search/mod.rs +++ b/crates/mcp-servers/src/coding/tools/web_search/mod.rs @@ -83,10 +83,6 @@ impl WebSearcher { Ok(Self::with_client(BraveSearchClient::new()?)) } - pub fn with_api_key(api_key: String) -> Self { - Self::with_client(BraveSearchClient::with_api_key(api_key)) - } - /// Performs a web search with the given parameters pub async fn search(&self, args: WebSearchInput, mut on_retry: T) -> Result where @@ -144,12 +140,6 @@ impl WebSearcher { } } -impl Default for WebSearcher { - fn default() -> Self { - Self::with_api_key(std::env::var("BRAVE_SEARCH_API_KEY").expect("BRAVE_SEARCH_API_KEY must be set")) - } -} - fn filter_allowed_domains(results: Vec, allowed_domains: &[String]) -> Vec { let allowed_set: HashSet = allowed_domains.iter().cloned().collect(); diff --git a/crates/mcp-servers/src/coding/tools/web_search/search_client.rs b/crates/mcp-servers/src/coding/tools/web_search/search_client.rs index d4e831225..a25f72569 100644 --- a/crates/mcp-servers/src/coding/tools/web_search/search_client.rs +++ b/crates/mcp-servers/src/coding/tools/web_search/search_client.rs @@ -3,6 +3,8 @@ use futures::future::BoxFuture; use reqwest::header::RETRY_AFTER; use serde::Deserialize; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use crate::coding::error::WebSearchError; @@ -56,16 +58,6 @@ impl BraveSearchClient { Ok(Self { client, api_key }) } - - /// Creates a new `BraveSearchClient` with an explicit API key - pub fn with_api_key(api_key: String) -> Self { - let client = reqwest::Client::builder() - .timeout(Duration::from_millis(DEFAULT_TIMEOUT_MS)) - .build() - .expect("Failed to build HTTP client"); - - Self { client, api_key } - } } impl SearchClient for BraveSearchClient { @@ -168,8 +160,7 @@ type SearchOutcome = Result, WebSearchError>; #[derive(Debug, Clone)] pub struct FakeSearchClient { responses: std::sync::Arc>>>, - search_history: std::sync::Arc>>, - default_response: Option>, + call_count: std::sync::Arc, /// When set, responses are served in call order (indexed by call count) /// instead of being looked up by query. Use for retry/error tests. sequential_responses: Option>>>, @@ -187,8 +178,7 @@ impl FakeSearchClient { pub fn new() -> Self { Self { responses: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), - search_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), - default_response: None, + call_count: std::sync::Arc::default(), sequential_responses: None, } } @@ -198,36 +188,18 @@ impl FakeSearchClient { self } - pub fn with_default(mut self, results: Vec) -> Self { - self.default_response = Some(results); - self - } - /// Queue a sequence of responses served in call order, regardless of query. /// Enables retry/error tests where the same query returns different results. pub fn with_sequential_results(mut self, responses: Vec) -> Self { self.sequential_responses = Some(std::sync::Arc::new(std::sync::Mutex::new(responses))); self } - - pub fn search_count(&self) -> usize { - self.search_history.lock().unwrap().len() - } - - pub fn searched_queries(&self) -> Vec { - self.search_history.lock().unwrap().clone() - } } #[cfg(test)] impl SearchClient for FakeSearchClient { fn search(&self, params: SearchParams) -> BoxFuture<'_, Result, WebSearchError>> { - let call_index = { - let mut history = self.search_history.lock().unwrap(); - let index = history.len(); - history.push(params.query.clone()); - index - }; + let call_index = self.call_count.fetch_add(1, Ordering::SeqCst); if let Some(ref sequential) = self.sequential_responses { return Box::pin(std::future::ready(sequential.lock().unwrap().get(call_index).cloned().unwrap_or_else( @@ -236,74 +208,8 @@ impl SearchClient for FakeSearchClient { } let responses = self.responses.lock().unwrap(); - Box::pin(std::future::ready(if let Some(results) = responses.get(¶ms.query) { - Ok(results.clone()) - } else if let Some(ref default) = self.default_response { - Ok(default.clone()) - } else { - Err(WebSearchError::ApiError(format!("No fake response configured for query: {}", params.query))) - })) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_search_params_limits_count() { - let params = SearchParams { query: "test".to_string(), count: 100 }; - let _ = params; // Just ensure it compiles - } - - #[tokio::test] - async fn test_fake_client_returns_configured_results() { - let fake = FakeSearchClient::new().with_results( - "rust programming", - vec![RawSearchResult { - title: "Rust Programming Language".to_string(), - url: "https://rust-lang.org".to_string(), - description: "A systems programming language".to_string(), - }], - ); - - let results = fake.search(SearchParams { query: "rust programming".to_string(), count: 10 }).await.unwrap(); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].title, "Rust Programming Language"); - } - - #[tokio::test] - async fn test_fake_client_tracks_search_history() { - let fake = FakeSearchClient::new().with_results("query1", vec![]).with_results("query2", vec![]); - - fake.search(SearchParams { query: "query1".to_string(), count: 10 }).await.unwrap(); - - fake.search(SearchParams { query: "query2".to_string(), count: 10 }).await.unwrap(); - - assert_eq!(fake.search_count(), 2); - assert_eq!(fake.searched_queries(), vec!["query1".to_string(), "query2".to_string()]); - } - - #[tokio::test] - async fn test_fake_client_with_default_response() { - let fake = FakeSearchClient::new().with_default(vec![RawSearchResult { - title: "Default Result".to_string(), - url: "https://example.com".to_string(), - description: "Default description".to_string(), - }]); - - let results = fake.search(SearchParams { query: "any query".to_string(), count: 10 }).await.unwrap(); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].title, "Default Result"); - } - - #[tokio::test] - async fn test_fake_client_missing_query_returns_error() { - let fake = FakeSearchClient::new(); - let result = fake.search(SearchParams { query: "not configured".to_string(), count: 10 }).await; - - assert!(result.is_err()); + Box::pin(std::future::ready(responses.get(¶ms.query).cloned().ok_or_else(|| { + WebSearchError::ApiError(format!("No fake response configured for query: {}", params.query)) + }))) } } diff --git a/crates/mcp-servers/src/docs/lsp_error.md b/crates/mcp-servers/src/docs/lsp_error.md index b78265670..c45a4f5ea 100644 --- a/crates/mcp-servers/src/docs/lsp_error.md +++ b/crates/mcp-servers/src/docs/lsp_error.md @@ -9,8 +9,3 @@ Errors from LSP code intelligence operations. - **`InvalidPath`** -- A requested path cannot be represented as a file URI. - **`SymbolNotFound`** -- The requested symbol could not be found. - **`InvalidPosition`** -- The requested source position is invalid. -- **`Transport`** -- Transport or protocol error during LSP message exchange. - -# Type alias - -The module provides `type Result = std::result::Result` for convenience. diff --git a/crates/mcp-servers/src/docs/lsp_registry.md b/crates/mcp-servers/src/docs/lsp_registry.md index 4ec795f77..aa987daba 100644 --- a/crates/mcp-servers/src/docs/lsp_registry.md +++ b/crates/mcp-servers/src/docs/lsp_registry.md @@ -28,7 +28,6 @@ let registry: Arc = LspRegistry::new_and_spawn("/my/project".into() - [`get_or_spawn`](LspRegistry::get_or_spawn) -- Get or create a client for the language of a given file path. - [`get_or_spawn_for_language`](LspRegistry::get_or_spawn_for_language) -- Get or create a client for a specific language ID. - [`active_clients`](LspRegistry::active_clients) -- List all currently connected LSP clients. -- [`has_config_for`](LspRegistry::has_config_for) -- Check if the daemon has an LSP config for a file's language. # See also diff --git a/crates/mcp-servers/src/docs/prompt_rule_matcher.md b/crates/mcp-servers/src/docs/prompt_rule_matcher.md index 973103f63..b7e0d1515 100644 --- a/crates/mcp-servers/src/docs/prompt_rule_matcher.md +++ b/crates/mcp-servers/src/docs/prompt_rule_matcher.md @@ -6,7 +6,7 @@ When a file is read through [`CodingMcp`](crate::CodingMcp), the matcher checks 1. Rules declare read triggers as glob patterns in their frontmatter (e.g. `triggers.read: ["*.rs", "src/**/*.ts"]`). 2. On each `read_file` call, [`get_matched_rules`](PromptRuleMatcher::get_matched_rules) tests the file path against all trigger globs. -3. Matched rules fire **once per session** -- subsequent reads of files matching the same rule return an empty result. Call [`clear`](PromptRuleMatcher::clear) to reset. +3. Matched rules fire **once per session** -- subsequent reads of files matching the same rule return an empty result. # See also diff --git a/crates/mcp-servers/src/file_ops.rs b/crates/mcp-servers/src/file_ops.rs index 9487146c0..50c6e5f88 100644 --- a/crates/mcp-servers/src/file_ops.rs +++ b/crates/mcp-servers/src/file_ops.rs @@ -49,9 +49,6 @@ pub enum FileError { #[error("{} edit(s) failed for file {path}; no changes were written:\n{}", .failures.len(), format_failures(.failures))] EditsFailed { path: String, failures: Vec }, - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), } /// A single edit that could not be applied. `index` is the diff --git a/crates/mcp-servers/src/lsp/diagnostics.rs b/crates/mcp-servers/src/lsp/diagnostics.rs index 5bbc0a5dc..3201ea989 100644 --- a/crates/mcp-servers/src/lsp/diagnostics.rs +++ b/crates/mcp-servers/src/lsp/diagnostics.rs @@ -110,13 +110,6 @@ pub struct DiagnosticCounts { pub total: usize, } -impl DiagnosticCounts { - /// Returns true if there are any errors - pub fn has_errors(&self) -> bool { - self.errors > 0 - } -} - impl fmt::Display for DiagnosticCounts { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} errors, {} warnings, {} info, {} hints", self.errors, self.warnings, self.infos, self.hints) @@ -195,16 +188,5 @@ mod tests { assert_eq!(counts.infos, 0); assert_eq!(counts.hints, 0); assert_eq!(counts.total, 3); - assert!(counts.has_errors()); - } - - #[test] - fn test_uri_to_path() { - // Unix-style path - assert_eq!(uri_to_path(&make_uri("/path/to/file.rs")), "/path/to/file.rs"); - - // Non-file URI - let non_file_uri: Uri = "https://example.com/file.rs".parse().unwrap(); - assert_eq!(uri_to_path(&non_file_uri), "https://example.com/file.rs"); } } diff --git a/crates/mcp-servers/src/lsp/error.rs b/crates/mcp-servers/src/lsp/error.rs index d4fda42cf..c4f9325d5 100644 --- a/crates/mcp-servers/src/lsp/error.rs +++ b/crates/mcp-servers/src/lsp/error.rs @@ -34,16 +34,9 @@ pub enum LspError { #[error("Invalid source position: {0}")] InvalidPosition(String), - /// The requested query was empty or otherwise invalid. - #[error("Invalid query: {0}")] - InvalidQuery(String), - /// An LSP edit produced an invalid or unsupported change. #[error("Invalid edit: {0}")] InvalidEdit(String), - - #[error("Transport error: {0}")] - Transport(String), } impl From for LspError { @@ -51,6 +44,3 @@ impl From for LspError { Self::InvalidPath(error.to_string()) } } - -/// Result type alias for LSP operations -pub type Result = std::result::Result; diff --git a/crates/mcp-servers/src/lsp/registry.rs b/crates/mcp-servers/src/lsp/registry.rs index 51b3c445b..7b10c2cdf 100644 --- a/crates/mcp-servers/src/lsp/registry.rs +++ b/crates/mcp-servers/src/lsp/registry.rs @@ -120,11 +120,6 @@ impl LspRegistry { Ok(connected) } - /// Get the LSP daemon client for a specific language, if already connected - pub async fn get_client_for_language(&self, language_id: LanguageId) -> Option> { - connected(self.slot(language_id).ok()?).await - } - /// Get all active LSP daemon clients pub async fn active_clients(&self) -> Vec> { let mut active = Vec::new(); @@ -136,14 +131,6 @@ impl LspRegistry { active } - /// Check if an LSP is configured for a given file path - /// - /// This checks the daemon's configuration registry. - pub fn has_config_for(&self, file_path: &Path) -> bool { - let language_id = LanguageId::from_path(file_path); - get_config_for_language(language_id).is_some() - } - /// Connect to LSP daemon for all detected project languages. /// /// This scans the project root for manifest files (Cargo.toml, package.json, etc.) @@ -315,16 +302,6 @@ mod tests { assert!(std::ptr::eq(registry.slot(LanguageId::C).unwrap(), registry.slot(LanguageId::Cpp).unwrap())); } - #[test] - fn test_has_config_for() { - let registry = LspRegistry::new(PathBuf::from("/tmp")); - - assert!(registry.has_config_for(Path::new("foo.rs"))); - assert!(registry.has_config_for(Path::new("bar.ts"))); - assert!(registry.has_config_for(Path::new("baz.py"))); - assert!(!registry.has_config_for(Path::new("unknown.xyz"))); - } - #[tokio::test] async fn test_no_clients_initially() { let registry = LspRegistry::new(PathBuf::from("/tmp")); diff --git a/crates/mcp-servers/src/testing/web_fetch.rs b/crates/mcp-servers/src/testing/web_fetch.rs index d2812734b..e9b059dd4 100644 --- a/crates/mcp-servers/src/testing/web_fetch.rs +++ b/crates/mcp-servers/src/testing/web_fetch.rs @@ -8,16 +8,11 @@ use crate::coding::tools::web_fetch::{HttpClient, HttpResponse}; /// In-memory [`HttpClient`] fake that scripts responses per URL. /// -/// Requests to URLs without a scripted result (or without a -/// [`FakeHttpClient::with_default`] fallback) fail with -/// [`WebFetchError::RequestFailed`]. The fake is cloneable and records every -/// fetch for assertions via [`FakeHttpClient::fetch_count`] and -/// [`FakeHttpClient::fetched_urls`]. +/// Requests to URLs without a scripted result fail with +/// [`WebFetchError::RequestFailed`]. #[derive(Debug, Clone)] pub struct FakeHttpClient { responses: Arc>>, - fetch_history: Arc>>, - default_response: Option, } impl Default for FakeHttpClient { @@ -28,11 +23,7 @@ impl Default for FakeHttpClient { impl FakeHttpClient { pub fn new() -> Self { - Self { - responses: Arc::new(Mutex::new(HashMap::new())), - fetch_history: Arc::new(Mutex::new(Vec::new())), - default_response: None, - } + Self { responses: Arc::new(Mutex::new(HashMap::new())) } } /// Scripts a full response for `url`, including redirects via `final_url`. @@ -65,33 +56,14 @@ impl FakeHttpClient { self.responses.lock().unwrap().insert(url.to_string(), FakeResponse::RequestFailed(message.to_string())); self } - - /// Responds with `response` for any URL that has no scripted result. - pub fn with_default(mut self, response: HttpResponse) -> Self { - self.default_response = Some(response); - self - } - - pub fn fetch_count(&self) -> usize { - self.fetch_history.lock().unwrap().len() - } - - pub fn fetched_urls(&self) -> Vec { - self.fetch_history.lock().unwrap().clone() - } } impl HttpClient for FakeHttpClient { fn fetch<'a>(&'a self, url: &'a str, _timeout: Duration) -> BoxFuture<'a, Result> { - self.fetch_history.lock().unwrap().push(url.to_string()); - let responses = self.responses.lock().unwrap(); Box::pin(std::future::ready(match responses.get(url) { Some(response) => response.clone().into_result(), - None => self - .default_response - .clone() - .ok_or_else(|| WebFetchError::RequestFailed(format!("No fake response configured for URL: {url}"))), + None => Err(WebFetchError::RequestFailed(format!("No fake response configured for URL: {url}"))), })) } } diff --git a/crates/mcp-servers/src/workspace_paths.rs b/crates/mcp-servers/src/workspace_paths.rs index 1fcaa8397..ccc9bc801 100644 --- a/crates/mcp-servers/src/workspace_paths.rs +++ b/crates/mcp-servers/src/workspace_paths.rs @@ -10,10 +10,6 @@ impl WorkspacePaths { Self { root: root.into() } } - pub fn current() -> Self { - Self::new(current_dir()) - } - pub fn root(&self) -> &Path { &self.root } @@ -26,10 +22,6 @@ impl WorkspacePaths { resolve_file(&self.root, raw) } - pub fn resolve_dir(&self, raw: Option<&str>) -> PathBuf { - resolve_dir(&self.root, raw) - } - pub fn relative_path(&self, path: &str) -> String { relative_path(&self.root, path) } @@ -68,15 +60,6 @@ pub fn resolve_file(root: &Path, raw: &str) -> Result) -> PathBuf { - match raw { - Some(raw) if !raw.trim().is_empty() => resolve_path(root, PathBuf::from(raw)), - _ => root.to_path_buf(), - } -} - #[cfg(test)] mod tests { use super::*; @@ -86,7 +69,6 @@ mod tests { let workspace = WorkspacePaths::new("/workspace"); assert_eq!(workspace.root(), Path::new("/workspace")); assert_eq!(workspace.resolve_path(PathBuf::from("src/main.rs")), PathBuf::from("/workspace/src/main.rs")); - assert_eq!(workspace.resolve_dir(None), PathBuf::from("/workspace")); assert_eq!(workspace.relative_path("/workspace/src/main.rs"), "src/main.rs"); assert_eq!(workspace.relative_path("/elsewhere/lib.rs"), "/elsewhere/lib.rs"); } @@ -122,10 +104,4 @@ mod tests { let err = resolve_file(Path::new("/workspace"), " \t ").unwrap_err(); assert!(err.to_string().contains("file path is required")); } - - #[test] - fn optional_empty_dir_resolves_to_root() { - assert_eq!(resolve_dir(Path::new("/workspace"), Some(" ")), PathBuf::from("/workspace")); - assert_eq!(resolve_dir(Path::new("/workspace"), None), PathBuf::from("/workspace")); - } } From 7047ec239528af15d94e8f8cd97b774f45d7f2ff Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 15:55:20 -0700 Subject: [PATCH 04/26] refactor(aether-lspd): remove dead language catalog api Delete from_lsp_id, extensions_for_alias and LanguageId::primary_extension, which had no callers, along with the aliases and primary_extension metadata fields that only they read. --- .../aether-lspd/src/docs/language_catalog.md | 3 +- crates/aether-lspd/src/language_catalog.rs | 272 ++---------------- crates/aether-lspd/src/lib.rs | 2 +- 3 files changed, 29 insertions(+), 248 deletions(-) diff --git a/crates/aether-lspd/src/docs/language_catalog.md b/crates/aether-lspd/src/docs/language_catalog.md index 9ee9be436..784f36e97 100644 --- a/crates/aether-lspd/src/docs/language_catalog.md +++ b/crates/aether-lspd/src/docs/language_catalog.md @@ -6,7 +6,6 @@ Identifies a programming language for LSP server selection. - [`from_extension`](LanguageId::from_extension) -- Detect from a file extension (e.g. `"rs"` → `Rust`). - [`from_path`](LanguageId::from_path) -- Detect from a file path. Falls back to `PlainText` for unknown extensions. -- [`from_lsp_id`](crate::from_lsp_id) -- Parse an LSP language ID string (e.g. `"typescriptreact"` → `TypeScriptReact`). - [`as_str`](LanguageId::as_str) -- Get the LSP language ID string for this variant. - [`detect_project_languages`](crate::detect_project_languages) -- Detect configured languages from project manifests. @@ -28,4 +27,4 @@ Languages that share a server implementation also share a daemon socket. For exa # Metadata -[`LANGUAGE_METADATA`](crate::LANGUAGE_METADATA) provides a static list of all language entries with their extensions, aliases, and primary extension. Use [`metadata_for`](crate::metadata_for) to look up a single language, or [`extensions_for_alias`](crate::extensions_for_alias) to find all file extensions matching a language name. [`server_metadata_for_language`](crate::server_metadata_for_language) provides server display and installation guidance. +[`LANGUAGE_METADATA`](crate::LANGUAGE_METADATA) provides a static list of all language entries with their file extensions. Use [`metadata_for`](crate::metadata_for) to look up a single language. [`server_metadata_for_language`](crate::server_metadata_for_language) provides server display and installation guidance. diff --git a/crates/aether-lspd/src/language_catalog.rs b/crates/aether-lspd/src/language_catalog.rs index 85c1512a0..85d812323 100644 --- a/crates/aether-lspd/src/language_catalog.rs +++ b/crates/aether-lspd/src/language_catalog.rs @@ -78,10 +78,6 @@ impl LanguageId { pub fn from_path(path: &Path) -> Self { path.extension().and_then(|e| e.to_str()).and_then(Self::from_extension).unwrap_or(Self::PlainText) } - - pub fn primary_extension(self) -> Option<&'static str> { - metadata_for(self).and_then(|metadata| metadata.primary_extension) - } } #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] @@ -130,8 +126,6 @@ pub struct LanguageServerMetadata { #[derive(Debug, Clone, Copy)] pub struct LanguageMetadata { pub id: LanguageId, - pub primary_extension: Option<&'static str>, - pub aliases: &'static [&'static str], pub extensions: &'static [&'static str], } @@ -216,239 +210,73 @@ const PROJECT_LANGUAGE_SPECS: &[(LanguageId, &[&str])] = &[ const LANGUAGE_SPECS: &[LanguageSpec] = &[ LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Rust, - primary_extension: Some("rs"), - aliases: &["rust", "rs"], - extensions: &["rs"], - }, + metadata: LanguageMetadata { id: LanguageId::Rust, extensions: &["rs"] }, server_kind: Some(ServerKind::RustAnalyzer), }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Python, - primary_extension: Some("py"), - aliases: &["python", "py"], - extensions: &["py", "pyi", "pyw"], - }, + metadata: LanguageMetadata { id: LanguageId::Python, extensions: &["py", "pyi", "pyw"] }, server_kind: Some(ServerKind::Pyright), }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::JavaScript, - primary_extension: Some("js"), - aliases: &["javascript", "js"], - extensions: &["js", "mjs"], - }, + metadata: LanguageMetadata { id: LanguageId::JavaScript, extensions: &["js", "mjs"] }, server_kind: Some(ServerKind::Tsc), }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::JavaScriptReact, - primary_extension: Some("jsx"), - aliases: &["javascript", "js", "javascriptreact", "jsx"], - extensions: &["jsx"], - }, + metadata: LanguageMetadata { id: LanguageId::JavaScriptReact, extensions: &["jsx"] }, server_kind: Some(ServerKind::Tsc), }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::TypeScript, - primary_extension: Some("ts"), - aliases: &["typescript", "ts"], - extensions: &["ts"], - }, + metadata: LanguageMetadata { id: LanguageId::TypeScript, extensions: &["ts"] }, server_kind: Some(ServerKind::Tsc), }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::TypeScriptReact, - primary_extension: Some("tsx"), - aliases: &["typescript", "ts", "typescriptreact", "tsx"], - extensions: &["tsx"], - }, + metadata: LanguageMetadata { id: LanguageId::TypeScriptReact, extensions: &["tsx"] }, server_kind: Some(ServerKind::Tsc), }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Go, - primary_extension: Some("go"), - aliases: &["go"], - extensions: &["go"], - }, + metadata: LanguageMetadata { id: LanguageId::Go, extensions: &["go"] }, server_kind: Some(ServerKind::Gopls), }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Java, extensions: &["java"] }, server_kind: None }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Java, - primary_extension: Some("java"), - aliases: &["java"], - extensions: &["java"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::C, - primary_extension: Some("c"), - aliases: &["c"], - extensions: &["c", "h"], - }, + metadata: LanguageMetadata { id: LanguageId::C, extensions: &["c", "h"] }, server_kind: Some(ServerKind::Clangd), }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Cpp, - primary_extension: Some("cpp"), - aliases: &["cpp", "c++"], - extensions: &["cpp", "cxx", "cc", "hpp", "hxx", "hh"], - }, + metadata: LanguageMetadata { id: LanguageId::Cpp, extensions: &["cpp", "cxx", "cc", "hpp", "hxx", "hh"] }, server_kind: Some(ServerKind::Clangd), }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::CSharp, extensions: &["cs"] }, server_kind: None }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Ruby, extensions: &["rb"] }, server_kind: None }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Php, extensions: &["php"] }, server_kind: None }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Swift, extensions: &["swift"] }, server_kind: None }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::CSharp, - primary_extension: Some("cs"), - aliases: &["csharp", "cs"], - extensions: &["cs"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Ruby, - primary_extension: Some("rb"), - aliases: &["ruby", "rb"], - extensions: &["rb"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Php, - primary_extension: Some("php"), - aliases: &["php"], - extensions: &["php"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Swift, - primary_extension: Some("swift"), - aliases: &["swift"], - extensions: &["swift"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Kotlin, - primary_extension: Some("kt"), - aliases: &["kotlin"], - extensions: &["kt", "kts"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Scala, - primary_extension: Some("scala"), - aliases: &["scala"], - extensions: &["scala"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Html, - primary_extension: Some("html"), - aliases: &["html"], - extensions: &["html", "htm"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Css, - primary_extension: Some("css"), - aliases: &["css"], - extensions: &["css"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Json, - primary_extension: Some("json"), - aliases: &["json"], - extensions: &["json"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Yaml, - primary_extension: Some("yaml"), - aliases: &["yaml", "yml"], - extensions: &["yaml", "yml"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Toml, - primary_extension: Some("toml"), - aliases: &["toml"], - extensions: &["toml"], - }, - server_kind: None, - }, - LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Markdown, - primary_extension: Some("md"), - aliases: &["markdown", "md"], - extensions: &["md", "markdown"], - }, + metadata: LanguageMetadata { id: LanguageId::Kotlin, extensions: &["kt", "kts"] }, server_kind: None, }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Scala, extensions: &["scala"] }, server_kind: None }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Xml, - primary_extension: Some("xml"), - aliases: &["xml"], - extensions: &["xml"], - }, + metadata: LanguageMetadata { id: LanguageId::Html, extensions: &["html", "htm"] }, server_kind: None, }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Css, extensions: &["css"] }, server_kind: None }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Json, extensions: &["json"] }, server_kind: None }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::Sql, - primary_extension: Some("sql"), - aliases: &["sql"], - extensions: &["sql"], - }, + metadata: LanguageMetadata { id: LanguageId::Yaml, extensions: &["yaml", "yml"] }, server_kind: None, }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Toml, extensions: &["toml"] }, server_kind: None }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::ShellScript, - primary_extension: Some("sh"), - aliases: &["sh", "shell", "bash"], - extensions: &["sh", "bash", "zsh"], - }, + metadata: LanguageMetadata { id: LanguageId::Markdown, extensions: &["md", "markdown"] }, server_kind: None, }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Xml, extensions: &["xml"] }, server_kind: None }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::Sql, extensions: &["sql"] }, server_kind: None }, LanguageSpec { - metadata: LanguageMetadata { - id: LanguageId::PlainText, - primary_extension: None, - aliases: &["plaintext", "text", "txt"], - extensions: &["txt"], - }, + metadata: LanguageMetadata { id: LanguageId::ShellScript, extensions: &["sh", "bash", "zsh"] }, server_kind: None, }, + LanguageSpec { metadata: LanguageMetadata { id: LanguageId::PlainText, extensions: &["txt"] }, server_kind: None }, ]; pub static LANGUAGE_METADATA: LazyLock> = @@ -516,19 +344,6 @@ pub fn metadata_for(id: LanguageId) -> Option<&'static LanguageMetadata> { LANGUAGE_METADATA.iter().find(|metadata| metadata.id == id) } -pub fn from_lsp_id(lsp_id: &str) -> Option { - LANGUAGE_SPECS.iter().find(|spec| spec.metadata.id.as_str() == lsp_id).map(|spec| spec.metadata.id) -} - -pub fn extensions_for_alias(alias: &str) -> Vec<&'static str> { - let lower = alias.to_lowercase(); - LANGUAGE_SPECS - .iter() - .filter(|spec| spec.metadata.aliases.iter().any(|candidate| *candidate == lower)) - .flat_map(|spec| spec.metadata.extensions.iter().copied()) - .collect() -} - pub fn detect_project_languages(root: &Path) -> Vec { PROJECT_LANGUAGE_SPECS .iter() @@ -593,40 +408,7 @@ mod tests { fn metadata_for_returns_correct_data() { let meta = metadata_for(LanguageId::Rust).unwrap(); assert_eq!(meta.id.as_str(), "rust"); - assert_eq!(meta.primary_extension, Some("rs")); - assert!(meta.aliases.contains(&"rust")); - assert!(meta.aliases.contains(&"rs")); - } - - #[test] - fn from_lsp_id_resolves_known_languages() { - assert_eq!(from_lsp_id("rust"), Some(LanguageId::Rust)); - assert_eq!(from_lsp_id("typescriptreact"), Some(LanguageId::TypeScriptReact)); - assert_eq!(from_lsp_id("unknown"), None); - } - - #[test] - fn primary_extension_delegates_to_catalog() { - assert_eq!(LanguageId::Rust.primary_extension(), Some("rs")); - assert_eq!(LanguageId::Python.primary_extension(), Some("py")); - assert_eq!(LanguageId::PlainText.primary_extension(), None); - } - - #[test] - fn extensions_for_alias_includes_related_variants() { - let js_exts = extensions_for_alias("javascript"); - assert!(js_exts.contains(&"js")); - assert!(js_exts.contains(&"mjs")); - assert!(js_exts.contains(&"jsx")); - - let ts_exts = extensions_for_alias("typescript"); - assert!(ts_exts.contains(&"ts")); - assert!(ts_exts.contains(&"tsx")); - - let sh_exts = extensions_for_alias("bash"); - assert!(sh_exts.contains(&"sh")); - assert!(sh_exts.contains(&"bash")); - assert!(sh_exts.contains(&"zsh")); + assert_eq!(meta.extensions, &["rs"]); } #[test] diff --git a/crates/aether-lspd/src/lib.rs b/crates/aether-lspd/src/lib.rs index e9052a632..fe2312362 100644 --- a/crates/aether-lspd/src/lib.rs +++ b/crates/aether-lspd/src/lib.rs @@ -42,7 +42,7 @@ pub enum LspdRunError { pub use language_catalog::LanguageId; pub use language_catalog::{ LANGUAGE_METADATA, LanguageMetadata, LanguageServerMetadata, LspConfig, detect_project_languages, - extensions_for_alias, from_lsp_id, get_config_for_language, metadata_for, server_metadata_for_language, + get_config_for_language, metadata_for, server_metadata_for_language, }; pub use socket_path::{ensure_socket_dir, lockfile_path, log_file_path, socket_path}; From 2057fe1dfb21a54d1749904c052b742d30a352e9 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 16:02:28 -0700 Subject: [PATCH 05/26] refactor(wisp): remove dead code and unreachable branches Delete unused test helpers (TestTerminal, FakeFilesystem accessors and settings, FakeExecutor::filesystem, FakeGit::files), never-read Theme fields and LocalConfigValue.raw_meta. App::on_ui_event already drops key releases and maps repeats to presses, so the per-surface is_press guards could never reject a key; remove them and is_press itself. SettingsOverlay intercepts Esc before any pane sees it, so the panes' Esc and Nav::Close arms were unreachable. Drop the transcript commit.rows == 0 early return, which computed the same lines as the general path, and route ArtifactReviewScreen's submission through ElicitationResponder::accept_strings instead of rebuilding the content map by hand. --- crates/wisp/src/renderer/transcript.rs | 10 --- .../src/screens/artifact_review/screen.rs | 5 +- .../wisp/src/session/session_config_view.rs | 2 - crates/wisp/src/settings/overlay/mod.rs | 8 +- .../src/settings/overlay/model_selector.rs | 15 +--- crates/wisp/src/settings/overlay/picker.rs | 3 +- .../src/settings/overlay/provider_login.rs | 3 +- .../src/settings/overlay/server_status.rs | 7 +- crates/wisp/src/surfaces/elicitation.rs | 2 +- crates/wisp/src/surfaces/input.rs | 10 +-- crates/wisp/src/surfaces/modal/mod.rs | 5 +- crates/wisp/src/surfaces/workspace_picker.rs | 7 +- crates/wisp/src/testing.rs | 88 ------------------- crates/wisp/src/theme.rs | 6 -- 14 files changed, 22 insertions(+), 149 deletions(-) diff --git a/crates/wisp/src/renderer/transcript.rs b/crates/wisp/src/renderer/transcript.rs index f45428ee1..22effba27 100644 --- a/crates/wisp/src/renderer/transcript.rs +++ b/crates/wisp/src/renderer/transcript.rs @@ -47,16 +47,6 @@ impl Renderer { return Ok(Vec::new()); }; let previous_kind = kind_before(items, commit.item_index); - if commit.rows == 0 { - return self.lines( - &items[commit.item_index..], - previous_kind, - width, - app.content_padding(), - app.spinner_tick(), - ); - } - let (item_width, item_padding) = commit.dimensions(width, app.content_padding()); let mut lines = self.item_suffix(item, previous_kind, item_width, item_padding, app.spinner_tick(), commit.rows)?; diff --git a/crates/wisp/src/screens/artifact_review/screen.rs b/crates/wisp/src/screens/artifact_review/screen.rs index a2dd00e2a..386991a5e 100644 --- a/crates/wisp/src/screens/artifact_review/screen.rs +++ b/crates/wisp/src/screens/artifact_review/screen.rs @@ -2,7 +2,6 @@ use crate::screens::reviewer::{crossterm_event, theme_selection}; use crate::surfaces::elicitation::ElicitationResponder; use crate::surfaces::input::{ArtifactReviewOutput, ReviewOutcome, UiEvent}; use crate::surfaces::modal::frame::ModalFrame; -use agent_client_protocol::schema::v2::ElicitationContentValue; use crate::view::generation::Generation; use crate::{renderer::DrawContext, settings::builtin_review_theme_choices}; use clankerdiff_ratatui::ReviewCapabilities; @@ -89,9 +88,7 @@ impl ArtifactReviewScreen { } fn submit(&mut self, submission: ArtifactReviewSubmission) -> Vec { - self.responder.accept(Some(submission.fields().map(|(name, value)| { - (name.to_string(), ElicitationContentValue::String(value.to_string())) - }).collect())); + self.responder.accept_strings(submission.fields()); vec![match submission { ArtifactReviewSubmission::Approved => ArtifactReviewOutput::Approved, ArtifactReviewSubmission::Feedback { feedback } => ArtifactReviewOutput::Outcome(ReviewOutcome::Submitted(feedback)), diff --git a/crates/wisp/src/session/session_config_view.rs b/crates/wisp/src/session/session_config_view.rs index 082fe49f1..a9758b6af 100644 --- a/crates/wisp/src/session/session_config_view.rs +++ b/crates/wisp/src/session/session_config_view.rs @@ -30,7 +30,6 @@ pub struct LocalConfigValue { pub description: Option, pub is_disabled: bool, pub meta: SelectOptionMeta, - pub raw_meta: Option, } impl LocalConfigOption { @@ -59,7 +58,6 @@ impl LocalConfigOption { is_disabled: value.description.as_deref().is_some_and(|text| text.starts_with("Unavailable:")), description: value.description, meta: SelectOptionMeta::from_meta(value.meta.as_ref()), - raw_meta: value.meta, }) .collect(), multi_select: ConfigOptionMeta::from_meta(option.meta.as_ref()).multi_select, diff --git a/crates/wisp/src/settings/overlay/mod.rs b/crates/wisp/src/settings/overlay/mod.rs index ce4643eaa..d9741e5f2 100644 --- a/crates/wisp/src/settings/overlay/mod.rs +++ b/crates/wisp/src/settings/overlay/mod.rs @@ -12,7 +12,7 @@ use self::server_status::ServerStatusPane; use crate::renderer::DrawContext; use crate::session::platform::{BrowserOpener, ClipboardWriter}; use crate::session::session_config_view::{LocalConfigOption, LocalConfigView}; -use crate::surfaces::input::{ElicitationOutput, MouseAction, SettingsOutput, UiEvent, is_press}; +use crate::surfaces::input::{ElicitationOutput, MouseAction, SettingsOutput, UiEvent}; use crate::surfaces::modal::{ElicitationModal, frame::ModalFrame}; use crate::theme::Theme; use crate::view::selection::Direction; @@ -284,9 +284,9 @@ impl SettingsOverlay { /// above it. fn on_menu_event(&mut self, event: &UiEvent) -> Vec { match event { - UiEvent::Key(key) if is_press(*key) => return self.on_menu_key(*key), + UiEvent::Key(key) => return self.on_menu_key(*key), // The menu has no editor, so a paste has nowhere to land. - UiEvent::Key(_) | UiEvent::Paste(_) => {} + UiEvent::Paste(_) => {} UiEvent::Mouse(MouseAction::ScrollUp, _) => self.menu.step(Direction::Backward), UiEvent::Mouse(MouseAction::ScrollDown, _) => self.menu.step(Direction::Forward), UiEvent::Mouse(MouseAction::Click, position) => { @@ -371,7 +371,7 @@ impl SettingsOverlay { // Esc leaves the pane rather than the overlay, committing whatever the // pane batched up while it was open. let messages = match event { - UiEvent::Key(key) if is_press(key) && key.code == KeyCode::Esc => pane + UiEvent::Key(key) if key.code == KeyCode::Esc => pane .take_changes() .map(|mut messages| { messages.push(SettingsOutput::Close); diff --git a/crates/wisp/src/settings/overlay/model_selector.rs b/crates/wisp/src/settings/overlay/model_selector.rs index cd8b7fd2a..a6bf70b62 100644 --- a/crates/wisp/src/settings/overlay/model_selector.rs +++ b/crates/wisp/src/settings/overlay/model_selector.rs @@ -1,5 +1,5 @@ use super::{KeyHint, SettingsChange, SettingsMenuValue, message_for_change, value_match_key}; -use crate::surfaces::input::{Nav, SettingsOutput, UiEvent, is_press}; +use crate::surfaces::input::{Nav, SettingsOutput, UiEvent}; use crate::surfaces::modal::frame::MODAL_HORIZONTAL_PADDING; use crate::theme::Theme; use crate::view::filterable_list::FilterableList; @@ -173,9 +173,7 @@ impl ModelSelector { /// those never reach the shared picker navigation. #[allow(clippy::needless_pass_by_value)] pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec { - if let UiEvent::Key(key) = &event - && is_press(*key) - { + if let UiEvent::Key(key) = &event { match key.code { KeyCode::Tab => { self.cycle_reasoning(Direction::Forward); @@ -193,13 +191,6 @@ impl ModelSelector { } } match self.items.on_nav_event(&event) { - Nav::Close => self - .take_changes() - .map(|mut changes| { - changes.push(SettingsOutput::Close); - changes - }) - .unwrap_or_default(), Nav::Clicked => { self.toggle_focused(); Vec::new() @@ -208,7 +199,7 @@ impl ModelSelector { self.clamp_reasoning_to_focused(); Vec::new() } - Nav::Activate | Nav::Unhandled => Vec::new(), + Nav::Close | Nav::Activate | Nav::Unhandled => Vec::new(), } } } diff --git a/crates/wisp/src/settings/overlay/picker.rs b/crates/wisp/src/settings/overlay/picker.rs index 6627deb41..b55d6442d 100644 --- a/crates/wisp/src/settings/overlay/picker.rs +++ b/crates/wisp/src/settings/overlay/picker.rs @@ -44,7 +44,6 @@ impl SettingsPicker { #[allow(clippy::needless_pass_by_value)] pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec { match self.values.on_nav_event(&event) { - Nav::Close => vec![SettingsOutput::Close], Nav::Activate | Nav::Clicked => { let change = self .values @@ -53,7 +52,7 @@ impl SettingsPicker { .map(|value| SettingsChange { config_id: self.config_id.clone(), new_value: value.value.clone() }); change.iter().map(message_for_change).chain([SettingsOutput::Close]).collect() } - Nav::Moved | Nav::Unhandled => Vec::new(), + Nav::Close | Nav::Moved | Nav::Unhandled => Vec::new(), } } } diff --git a/crates/wisp/src/settings/overlay/provider_login.rs b/crates/wisp/src/settings/overlay/provider_login.rs index cd98b8c41..36bfc7e1a 100644 --- a/crates/wisp/src/settings/overlay/provider_login.rs +++ b/crates/wisp/src/settings/overlay/provider_login.rs @@ -49,13 +49,12 @@ impl ProviderLoginPane { #[allow(clippy::needless_pass_by_value)] pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec { match self.entries.on_nav_event(&event) { - Nav::Close => vec![SettingsOutput::Close], Nav::Activate | Nav::Clicked => one(self .entries .selected_entry() .filter(|entry| entry.status != ProviderLoginStatus::Authenticating) .map(|entry| SettingsOutput::AuthenticateProvider(entry.method_id.clone()))), - Nav::Moved | Nav::Unhandled => Vec::new(), + Nav::Close | Nav::Moved | Nav::Unhandled => Vec::new(), } } } diff --git a/crates/wisp/src/settings/overlay/server_status.rs b/crates/wisp/src/settings/overlay/server_status.rs index a690fe90b..b01eee833 100644 --- a/crates/wisp/src/settings/overlay/server_status.rs +++ b/crates/wisp/src/settings/overlay/server_status.rs @@ -1,5 +1,5 @@ use super::{LiveSettingsData, summarize}; -use crate::surfaces::input::{SettingsOutput, UiEvent, is_press, one}; +use crate::surfaces::input::{SettingsOutput, UiEvent, one}; use crate::surfaces::modal::frame::MODAL_HORIZONTAL_PADDING; use crate::theme::Theme; use crate::view::list_view::ListView; @@ -68,8 +68,8 @@ impl ServerStatusPane { #[allow(clippy::needless_pass_by_value)] pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec { match event { - UiEvent::Key(key) if is_press(key) => self.on_key(key), - UiEvent::Key(_) | UiEvent::Paste(_) => Vec::new(), + UiEvent::Key(key) => self.on_key(key), + UiEvent::Paste(_) => Vec::new(), UiEvent::Mouse(action, (_, row)) => match action.direction() { Some(direction) => self.scroll(direction), None => self.click(row), @@ -79,7 +79,6 @@ impl ServerStatusPane { fn on_key(&mut self, key: KeyEvent) -> Vec { match key.code { - KeyCode::Esc => vec![SettingsOutput::Close], KeyCode::Up => self.scroll(Direction::Backward), KeyCode::Down => self.scroll(Direction::Forward), KeyCode::Enter => self.activate(), diff --git a/crates/wisp/src/surfaces/elicitation.rs b/crates/wisp/src/surfaces/elicitation.rs index 61aa36bdb..f150e138f 100644 --- a/crates/wisp/src/surfaces/elicitation.rs +++ b/crates/wisp/src/surfaces/elicitation.rs @@ -22,7 +22,7 @@ impl ElicitationResponder { self.respond(ElicitationAction::Accept(ElicitationAcceptAction::new().content(content))); } - pub fn accept_strings(&mut self, content: [(&str, &str); T]) { + pub fn accept_strings<'a, 'b>(&mut self, content: impl IntoIterator) { self.accept(Some( content .into_iter() diff --git a/crates/wisp/src/surfaces/input.rs b/crates/wisp/src/surfaces/input.rs index 2b0908e27..5c86bc1ad 100644 --- a/crates/wisp/src/surfaces/input.rs +++ b/crates/wisp/src/surfaces/input.rs @@ -3,7 +3,7 @@ use crate::view::filterable_list::FilterableList; use crate::view::selection::Direction; use acp_utils::notifications::WorkspaceMoveTarget; use agent_client_protocol::schema::v2::SessionId; -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::path::PathBuf; const COMPOSED_MODIFIERS: KeyModifiers = KeyModifiers::CONTROL @@ -16,10 +16,6 @@ pub(crate) fn is_composed_char(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Char(_)) && key.modifiers.intersects(COMPOSED_MODIFIERS) } -pub(crate) fn is_press(key: KeyEvent) -> bool { - matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) -} - #[derive(Debug)] pub enum SessionPickerOutput { Close, @@ -136,8 +132,8 @@ impl FilterableList { /// the outcome and adds only its own behavior. pub(crate) fn on_nav_event(&mut self, event: &UiEvent) -> Nav { match event { - UiEvent::Key(key) if is_press(*key) => self.on_nav_key(*key), - UiEvent::Key(_) | UiEvent::Paste(_) => Nav::Unhandled, + UiEvent::Key(key) => self.on_nav_key(*key), + UiEvent::Paste(_) => Nav::Unhandled, UiEvent::Mouse(action, (_, row)) => match action.direction() { Some(direction) => { self.step(direction); diff --git a/crates/wisp/src/surfaces/modal/mod.rs b/crates/wisp/src/surfaces/modal/mod.rs index 6cd511836..6f3ea9c1b 100644 --- a/crates/wisp/src/surfaces/modal/mod.rs +++ b/crates/wisp/src/surfaces/modal/mod.rs @@ -17,7 +17,7 @@ use self::form::{FormAction, FormModal}; use self::frame::{MODAL_HORIZONTAL_PADDING, MODAL_VERTICAL_CHROME, ModalFrame}; use self::url::UrlModal; use crate::session::platform::{BrowserOpener, ClipboardWriter}; -use crate::surfaces::input::{ElicitationOutput, UiEvent, is_press}; +use crate::surfaces::input::{ElicitationOutput, UiEvent}; use crate::theme::Theme; use crate::view::widgets::{KeyHint, SCROLLBAR_WIDTH, key_hints}; use crate::view::wrap::as_u16; @@ -118,8 +118,7 @@ impl ElicitationModal { impl ElicitationModal { pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec { match event { - UiEvent::Key(key) if is_press(key) => self.on_key(key), - UiEvent::Key(_) => Vec::new(), + UiEvent::Key(key) => self.on_key(key), UiEvent::Paste(text) => { if let ModalKind::Form(form) = &mut self.kind { form.paste(&text); diff --git a/crates/wisp/src/surfaces/workspace_picker.rs b/crates/wisp/src/surfaces/workspace_picker.rs index b7e4296ef..8103aa443 100644 --- a/crates/wisp/src/surfaces/workspace_picker.rs +++ b/crates/wisp/src/surfaces/workspace_picker.rs @@ -1,5 +1,5 @@ use crate::renderer::DrawContext; -use crate::surfaces::input::{Nav, UiEvent, WorkspacePickerOutput, is_press}; +use crate::surfaces::input::{Nav, UiEvent, WorkspacePickerOutput}; use crate::theme::Theme; use crate::view::edit_buffer::{EditBuffer, apply_edit_key}; use crate::view::filterable_list::FilterableList; @@ -107,13 +107,12 @@ impl WorkspacePicker { pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec { if matches!(self.mode, Mode::NamingNew { .. }) { return match event { - UiEvent::Key(key) if is_press(key) => self.on_naming_key(key), + UiEvent::Key(key) => self.on_naming_key(key), UiEvent::Paste(text) => self.on_paste(&text), - UiEvent::Key(_) | UiEvent::Mouse(..) => Vec::new(), + UiEvent::Mouse(..) => Vec::new(), }; } if let UiEvent::Key(key) = &event - && is_press(*key) && key.code == KeyCode::Tab { return self.activate(); diff --git a/crates/wisp/src/testing.rs b/crates/wisp/src/testing.rs index 209e29360..560bebf78 100644 --- a/crates/wisp/src/testing.rs +++ b/crates/wisp/src/testing.rs @@ -94,10 +94,6 @@ impl FakeExecutor { &mut self.git } - pub fn filesystem(&self) -> &FakeFilesystem { - &self.filesystem - } - pub fn filesystem_mut(&mut self) -> &mut FakeFilesystem { &mut self.filesystem } @@ -263,7 +259,6 @@ impl FakeAgent { pub struct FakeFilesystem { files: BTreeMap>, directories: BTreeSet, - settings: Option, } impl FakeFilesystem { @@ -271,10 +266,6 @@ impl FakeFilesystem { Self::default() } - pub fn create_dir(&mut self, path: impl Into) { - self.directories.insert(path.into()); - } - pub fn write_file(&mut self, path: impl Into, contents: impl AsRef<[u8]>) { let path = path.into(); if let Some(parent) = path.parent() { @@ -283,38 +274,6 @@ impl FakeFilesystem { self.files.insert(path, contents.as_ref().to_vec()); } - pub fn remove_file(&mut self, path: &Path) -> bool { - self.files.remove(path).is_some() - } - - pub fn read_file(&self, path: &Path) -> Option<&[u8]> { - self.files.get(path).map(Vec::as_slice) - } - - pub fn read_to_string(&self, path: &Path) -> Option { - self.read_file(path).and_then(|contents| String::from_utf8(contents.to_vec()).ok()) - } - - pub fn contains(&self, path: &Path) -> bool { - self.files.contains_key(path) || self.directories.contains(path) - } - - pub fn files(&self) -> impl Iterator { - self.files.iter().map(|(path, contents)| (path.as_path(), contents.as_slice())) - } - - pub fn directories(&self) -> impl Iterator { - self.directories.iter().map(PathBuf::as_path) - } - - pub fn save_settings(&mut self, settings: UiSettings) { - self.settings = Some(settings); - } - - pub fn settings(&self) -> Option<&UiSettings> { - self.settings.as_ref() - } - /// The runtime's file index over the in-memory tree, minus gitignore /// semantics, which need a real repository. pub fn index_files(&self, root: &Path) -> Vec { @@ -465,10 +424,6 @@ impl FakeGit { self.state.lock().unwrap().files.get(path).cloned() } - pub fn files(&self) -> Vec { - self.state.lock().unwrap().files.values().cloned().collect() - } - pub fn commits(&self) -> Vec { self.state.lock().unwrap().commits.clone() } @@ -566,37 +521,6 @@ impl FakeGit { } } -/// Deterministic terminal wrapper used by focused golden tests. -pub struct TestTerminal { - terminal: Terminal, -} - -impl TestTerminal { - pub fn new(width: u16, height: u16) -> Self { - Self { terminal: test_terminal(TestBackend::new(width, height)) } - } - - pub fn terminal(&mut self) -> &mut Terminal { - &mut self.terminal - } - - pub fn resize(&mut self, width: u16, height: u16) { - self.terminal.backend_mut().resize(width, height); - } - - pub fn viewport(&mut self) -> Buffer { - viewport_buffer(&mut self.terminal) - } - - pub fn history(&mut self) -> Buffer { - history_buffer(&mut self.terminal) - } - - pub fn conversation(&mut self) -> Buffer { - conversation_buffer(&mut self.terminal) - } -} - /// A backend that records terminal history operations without involving a real /// terminal. It is intentionally small: only operations with an observable /// presentation contract are recorded. @@ -1894,18 +1818,6 @@ mod tests { assert!(matches!(executor.take_commands()[..], [Command::Filesystem(_), Command::Terminal(_)])); } - #[test] - fn fake_filesystem_persists_files_and_settings_in_memory() { - let mut filesystem = FakeFilesystem::new(); - let path = PathBuf::from("workspace/src/main.rs"); - filesystem.write_file(&path, "fn main() {}"); - filesystem.save_settings(UiSettings::default()); - - assert_eq!(filesystem.read_to_string(&path).as_deref(), Some("fn main() {}")); - assert!(filesystem.contains(Path::new("workspace/src"))); - assert!(filesystem.settings().is_some()); - } - #[test] fn fake_git_models_staging_and_discarding_state() { let mut git = FakeGit::new("workspace"); diff --git a/crates/wisp/src/theme.rs b/crates/wisp/src/theme.rs index 80b2d9852..0df708e6e 100644 --- a/crates/wisp/src/theme.rs +++ b/crates/wisp/src/theme.rs @@ -13,7 +13,6 @@ pub struct Theme { pub surface: Color, pub accent: Color, pub heading: Color, - pub link: Color, pub blockquote: Color, pub code_fg: Color, pub code_bg: Color, @@ -22,9 +21,7 @@ pub struct Theme { pub error: Color, pub info: Color, pub muted: Color, - pub diff_added_fg: Color, pub diff_added_bg: Color, - pub diff_removed_fg: Color, pub diff_removed_bg: Color, review: Arc, } @@ -92,7 +89,6 @@ impl Theme { surface: ui.surface, accent: ui.accent, heading: color(review.markdown.heading), - link: color(review.markdown.link), blockquote: color(review.markdown.quote), code_fg: color(review.markdown.code), code_bg: color(review.markdown.code_background), @@ -101,9 +97,7 @@ impl Theme { error: ui.destructive, info: ui.info, muted: ui.text_muted, - diff_added_fg: native.addition, diff_added_bg: native.addition_background, - diff_removed_fg: native.deletion, diff_removed_bg: native.deletion_background, review: Arc::new(review), } From 813058ff201dd2e6cd0831f44f5e635ac2f698bd Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 16:54:50 -0700 Subject: [PATCH 06/26] refactor(aether-cli): remove no-op code AcpOptions is deserialize-only, so its skip_serializing_if attributes never ran and missing Option fields already default to None; the generated schema is unchanged. Collapse the single-variant AcpRunOutcome into Result<(), AcpRunError>, drop the AgentKey argument that every RuntimeFactory::spawn impl ignored, inline the supports_prompt_image/audio aliases, and delete the headless::error re-export module. --- crates/aether-cli/src/acp/mod.rs | 23 ++------------------ crates/aether-cli/src/acp/session/actor.rs | 2 +- crates/aether-cli/src/acp/session/model.rs | 12 ++-------- crates/aether-cli/src/acp/session/runtime.rs | 3 --- crates/aether-cli/src/acp/state.rs | 7 +++--- crates/aether-cli/src/acp/testing.rs | 2 -- crates/aether-cli/src/headless/error.rs | 1 - crates/aether-cli/src/headless/mod.rs | 3 +-- crates/aether-cli/src/headless/run.rs | 2 +- crates/aether-cli/src/main.rs | 9 ++------ 10 files changed, 12 insertions(+), 52 deletions(-) delete mode 100644 crates/aether-cli/src/headless/error.rs diff --git a/crates/aether-cli/src/acp/mod.rs b/crates/aether-cli/src/acp/mod.rs index bea5b6a76..ebd5dea1b 100644 --- a/crates/aether-cli/src/acp/mod.rs +++ b/crates/aether-cli/src/acp/mod.rs @@ -81,31 +81,16 @@ pub struct AcpArgs { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(rename = "AetherAcpOptions")] pub struct AcpOptions { - #[serde(default, skip_serializing_if = "Option::is_none")] pub log_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub providers: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] pub settings: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub settings_file: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub trace_context: Option, } -/// Outcome of running the ACP server successfully. -#[derive(Debug)] -pub enum AcpRunOutcome { - /// The client disconnected cleanly (e.g. EOF on stdin). - CleanDisconnect, -} - /// Errors that terminate the ACP server run. #[derive(Debug, Error)] pub enum AcpRunError { @@ -145,18 +130,14 @@ pub enum AcpOptionsJsonError { UnsupportedReasoningEffort(#[from] ReasoningEffortError), } -pub async fn run_acp(args: AcpArgs) -> Result { +pub async fn run_acp(args: AcpArgs) -> Result<(), AcpRunError> { info!("Starting Aether ACP server"); let cwd = current_dir().unwrap_or_else(|_| PathBuf::from(".")); let state = Arc::new(create_acp_state(args, &cwd, SessionHooks::default())?); let connect_result = state.serve(Stdio::new(), state.stop_token()).await; state.shutdown_all().await; - - match connect_result { - Ok(()) => Ok(AcpRunOutcome::CleanDisconnect), - Err(err) => Err(AcpRunError::Protocol(err)), - } + connect_result.map_err(AcpRunError::Protocol) } #[derive(Debug)] diff --git a/crates/aether-cli/src/acp/session/actor.rs b/crates/aether-cli/src/acp/session/actor.rs index 12e3a1579..d06608c48 100644 --- a/crates/aether-cli/src/acp/session/actor.rs +++ b/crates/aether-cli/src/acp/session/actor.rs @@ -372,7 +372,7 @@ impl SessionActor { let runtime = tokio::select! { biased; () = self.cancel.cancelled() => return Err(SessionError::Cancelled), - runtime = self.runtime_factory.spawn(target.clone(), spec, messages, usage_seed, self.elicitation_tx.clone()) => runtime?, + runtime = self.runtime_factory.spawn(spec, messages, usage_seed, self.elicitation_tx.clone()) => runtime?, }; self.runtimes.insert(target.clone(), runtime); Ok(()) diff --git a/crates/aether-cli/src/acp/session/model.rs b/crates/aether-cli/src/acp/session/model.rs index d70b2c4e6..f6f15f817 100644 --- a/crates/aether-cli/src/acp/session/model.rs +++ b/crates/aether-cli/src/acp/session/model.rs @@ -12,14 +12,6 @@ fn needs_oauth_login(model: &LlmModel, store: &dyn OAuthCredentialStorage) -> bo model.oauth_provider_id().is_some_and(|id| !store.contains(id)) } -pub(crate) fn supports_prompt_image(model: &LlmModel) -> bool { - model.supports_image() -} - -pub(crate) fn supports_prompt_audio(model: &LlmModel) -> bool { - model.supports_audio() -} - pub(crate) fn unavailable_reason(model: &LlmModel, store: &dyn OAuthCredentialStorage) -> String { if needs_oauth_login(model, store) { return "Needs login".to_string(); @@ -90,8 +82,8 @@ pub(crate) fn build_model_config_option( let mut option = acp::SessionConfigSelectOption::new(value, name); let meta = SelectOptionMeta { reasoning_levels: m.effective_reasoning_levels(), - supports_image: supports_prompt_image(m), - supports_audio: supports_prompt_audio(m), + supports_image: m.supports_image(), + supports_audio: m.supports_audio(), }; if meta != SelectOptionMeta::default() { option = option.meta(meta.into_meta()); diff --git a/crates/aether-cli/src/acp/session/runtime.rs b/crates/aether-cli/src/acp/session/runtime.rs index 8dd5bac80..1b264438f 100644 --- a/crates/aether-cli/src/acp/session/runtime.rs +++ b/crates/aether-cli/src/acp/session/runtime.rs @@ -1,4 +1,3 @@ -use super::agent_key::AgentKey; use super::error::SessionError; use crate::runtime::{Runtime, RuntimeBuilder}; use aether_core::agent_spec::AgentSpec; @@ -55,7 +54,6 @@ impl AgentRuntime { pub(crate) trait RuntimeFactory: Send + Sync { async fn spawn( &self, - agent: AgentKey, spec: &AgentSpec, initial_messages: Vec, usage_seed: Option, @@ -85,7 +83,6 @@ impl ProductionRuntimeFactory { impl RuntimeFactory for ProductionRuntimeFactory { async fn spawn( &self, - _agent: AgentKey, spec: &AgentSpec, initial_messages: Vec, usage_seed: Option, diff --git a/crates/aether-cli/src/acp/state.rs b/crates/aether-cli/src/acp/state.rs index 3eafcb77f..79471a1b5 100644 --- a/crates/aether-cli/src/acp/state.rs +++ b/crates/aether-cli/src/acp/state.rs @@ -38,7 +38,6 @@ use super::session::actor::{SessionActor, SessionActorInit}; use super::session::config_setting::ConfigSetting; use super::session::factory::SessionFactory; use super::session::hooks::SessionHooks; -use super::session::model::supports_prompt_audio; use super::session::{SessionRegistry, paginate_summaries}; use crate::resolve::InitialSessionSelection; use crate::settings_args::SettingsSourceArgs; @@ -684,7 +683,7 @@ fn prompt_capabilities_for_models(models: &[LlmModel]) -> PromptCapabilities { PromptCapabilities::new() .embedded_context(PromptEmbeddedContextCapabilities::new()) .image(models.iter().any(LlmModel::supports_image).then(PromptImageCapabilities::new)) - .audio(models.iter().any(supports_prompt_audio).then(PromptAudioCapabilities::new)) + .audio(models.iter().any(LlmModel::supports_audio).then(PromptAudioCapabilities::new)) } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -720,7 +719,7 @@ pub(crate) fn validate_prompt_support(model_value: &str, content: &[ContentBlock if modalities.image && selected.iter().any(|model| !model.supports_image()) { return Err(Error::invalid_params()); } - if modalities.audio && selected.iter().any(|model| !supports_prompt_audio(model)) { + if modalities.audio && selected.iter().any(|model| !model.supports_audio()) { return Err(Error::invalid_params()); } @@ -739,7 +738,7 @@ mod tests { // Upstream model metadata changes weekly, so look the fixture up instead of pinning a model id. LlmModel::all() .iter() - .find(|model| !model.supports_image() && !supports_prompt_audio(model)) + .find(|model| !model.supports_image() && !model.supports_audio()) .cloned() .expect("catalog contains a text-only model") } diff --git a/crates/aether-cli/src/acp/testing.rs b/crates/aether-cli/src/acp/testing.rs index f1aae9944..fb1ddc8f8 100644 --- a/crates/aether-cli/src/acp/testing.rs +++ b/crates/aether-cli/src/acp/testing.rs @@ -856,7 +856,6 @@ struct FakeAgentDef { impl RuntimeFactory for FakeRuntimeFactory { async fn spawn( &self, - _agent: AgentKey, spec: &AgentSpec, initial_messages: Vec, usage_seed: Option, @@ -903,7 +902,6 @@ struct StubRuntimeFactory { impl RuntimeFactory for StubRuntimeFactory { async fn spawn( &self, - _agent: AgentKey, _spec: &AgentSpec, _initial_messages: Vec, _usage_seed: Option, diff --git a/crates/aether-cli/src/headless/error.rs b/crates/aether-cli/src/headless/error.rs deleted file mode 100644 index d8804263d..000000000 --- a/crates/aether-cli/src/headless/error.rs +++ /dev/null @@ -1 +0,0 @@ -pub use crate::error::CliError; diff --git a/crates/aether-cli/src/headless/mod.rs b/crates/aether-cli/src/headless/mod.rs index 0e5cbec79..d3d1ba727 100644 --- a/crates/aether-cli/src/headless/mod.rs +++ b/crates/aether-cli/src/headless/mod.rs @@ -1,11 +1,10 @@ -pub mod error; pub mod run; +use crate::error::CliError; use aether_core::agent_spec::AgentSpec; use aether_core::mcp::McpConfig; use aether_project::{AetherSettings, AgentCatalog, TelemetrySettings}; use aether_telemetry::AgentTraceContext; -use error::CliError; use llm::{ProviderConnectionOverride, ProviderConnectionOverrides}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/aether-cli/src/headless/run.rs b/crates/aether-cli/src/headless/run.rs index 70899eb18..530d9e323 100644 --- a/crates/aether-cli/src/headless/run.rs +++ b/crates/aether-cli/src/headless/run.rs @@ -10,8 +10,8 @@ use tokio::sync::mpsc; use crate::telemetry::build_telemetry_runtime; -use super::error::CliError; use super::{CliEventKind, RunConfig}; +use crate::error::CliError; use crate::output::{OutputFormat, print_message}; use crate::runtime::RuntimeBuilder; use crate::slash_commands::expand_slash_command_text; diff --git a/crates/aether-cli/src/main.rs b/crates/aether-cli/src/main.rs index 493563a43..83c68aea2 100644 --- a/crates/aether-cli/src/main.rs +++ b/crates/aether-cli/src/main.rs @@ -1,5 +1,5 @@ use aether_cli::acp::server::{ServerArgs, ServerRunError, run_server}; -use aether_cli::acp::{AcpArgs, AcpRunError, AcpRunOutcome, run_acp}; +use aether_cli::acp::{AcpArgs, AcpRunError, run_acp}; use aether_cli::client::{ClientArgs, ClientRunError, run_client}; use aether_cli::error::CliError; use aether_cli::generate_command::{GenerateArgs, GenerateCommandError, run as run_generate_command}; @@ -95,12 +95,7 @@ fn main() -> ExitCode { Some(Command::Generate(args)) => rt.block_on(run_generate_command(args)).map_err(Into::into), - Some(Command::Acp(args)) => rt - .block_on(run_acp(args)) - .map(|outcome| match outcome { - AcpRunOutcome::CleanDisconnect => ExitCode::SUCCESS, - }) - .map_err(Into::into), + Some(Command::Acp(args)) => rt.block_on(run_acp(args)).map(|()| ExitCode::SUCCESS).map_err(Into::into), Some(Command::Server(args)) => rt.block_on(run_server(args)).map(|()| ExitCode::SUCCESS).map_err(Into::into), From 436b219ed0ac6785221f1f4bfb7ef5e93ce56459 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 17:01:12 -0700 Subject: [PATCH 07/26] refactor: remove dead code in core, evals and auth Delete the aether-core anthropic_test example, which never used aether-core and ignored its --no-cache flag, along with the clap and chrono dev-dependencies only it needed. Remove the test-only Prompt::build_all and port its tests to PromptCache::render. Collapse JudgeError::InvalidJudgment to a single reason string since raw_response was always empty. Drop the uncalled BrowserOAuthHandler::new and wait_for_callback, and make open_browser private. --- Cargo.lock | 2 - crates/aether-auth/README.md | 2 +- crates/aether-auth/src/browser.rs | 18 +- crates/aether-auth/src/lib.rs | 2 +- crates/aether-auth/src/oauth.md | 2 +- crates/aether-core/Cargo.toml | 2 - crates/aether-core/examples/anthropic_test.rs | 170 ------------------ crates/aether-core/src/core/agent_builder.rs | 12 -- crates/aether-core/src/core/prompt.rs | 56 ++---- crates/aether-evals/src/judge.rs | 24 ++- 10 files changed, 31 insertions(+), 259 deletions(-) delete mode 100644 crates/aether-core/examples/anthropic_test.rs diff --git a/Cargo.lock b/Cargo.lock index 29a152188..5116d4fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,8 +118,6 @@ dependencies = [ "aether-utils", "async-stream", "base16ct 1.0.0", - "chrono", - "clap", "futures", "glob", "http", diff --git a/crates/aether-auth/README.md b/crates/aether-auth/README.md index 1456ab942..e8de257a4 100644 --- a/crates/aether-auth/README.md +++ b/crates/aether-auth/README.md @@ -19,7 +19,7 @@ OAuth credential storage and authorization flows for Aether. Provides a pluggabl - **`OAuthCredentialStorage`** -- Trait for persisting OAuth credentials keyed by provider ID, MCP server ID, or another credential key. - **`OAuthCredential`** -- Access token, refresh token, client ID, and expiry timestamp for a single OAuth identity. - **`OAuthHandler`** -- Trait implemented by consuming applications to drive the OAuth UI/UX (open a browser, wait for the redirect). -- **`BrowserOAuthHandler`** -- Default `OAuthHandler` that opens the system browser and listens on a dynamic local port. +- **`BrowserOAuthHandler`** -- Default `OAuthHandler` that opens the system browser and listens on a local port. - **`OsKeyringStore`** -- `OAuthCredentialStorage` backed by the OS keychain (macOS Keychain, Windows Credential Manager, Linux/FreeBSD Secret Service). Available under the `keyring` feature. - **`FakeOAuthCredentialStore`** -- In-memory `OAuthCredentialStorage` for tests. - **`McpCredentialStore`** -- Per-server adapter that binds an `OAuthCredentialStorage` to one MCP server ID and implements `rmcp::transport::auth::CredentialStore`. Available under the `mcp` feature. diff --git a/crates/aether-auth/src/browser.rs b/crates/aether-auth/src/browser.rs index 59f9e6280..3f7c0d720 100644 --- a/crates/aether-auth/src/browser.rs +++ b/crates/aether-auth/src/browser.rs @@ -8,21 +8,13 @@ use tokio::net::TcpListener; use tokio::time::timeout; /// Default `OAuthHandler` that opens the system browser and listens -/// for the OAuth callback on a dynamically-assigned local port. +/// for the OAuth callback on a local port. pub struct BrowserOAuthHandler { listener: TcpListener, redirect_uri: String, } impl BrowserOAuthHandler { - pub fn new() -> Result { - let std_listener = std::net::TcpListener::bind("127.0.0.1:0")?; - let port = std_listener.local_addr()?.port(); - std_listener.set_nonblocking(true)?; - let listener = TcpListener::from_std(std_listener)?; - Ok(Self { listener, redirect_uri: format!("http://127.0.0.1:{port}/oauth2callback") }) - } - /// Create a handler bound to a specific port with a custom redirect URI. pub fn with_redirect_uri(redirect_uri: impl Into, port: u16) -> Result { let std_listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))?; @@ -72,14 +64,8 @@ pub async fn accept_oauth_callback(listener: &TcpListener) -> Result Result { - let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await?; - accept_oauth_callback(&listener).await -} - /// Open a URL in the default browser. -pub fn open_browser(url: &str) -> Result<(), OAuthError> { +fn open_browser(url: &str) -> Result<(), OAuthError> { #[cfg(target_os = "macos")] { Command::new("open").arg(url).spawn().map_err(std::io::Error::other)?; diff --git a/crates/aether-auth/src/lib.rs b/crates/aether-auth/src/lib.rs index fc5ddc0af..b5c318421 100644 --- a/crates/aether-auth/src/lib.rs +++ b/crates/aether-auth/src/lib.rs @@ -7,7 +7,7 @@ pub mod error; mod fake; mod handler; -pub use browser::{BrowserOAuthHandler, accept_oauth_callback, open_browser, wait_for_callback}; +pub use browser::{BrowserOAuthHandler, accept_oauth_callback}; pub use credential::{OAuthCredential, OAuthCredentialStorage, oauth_http_client}; pub use encrypted_file::EncryptedFileOAuthCredentialStorage; pub use error::OAuthError; diff --git a/crates/aether-auth/src/oauth.md b/crates/aether-auth/src/oauth.md index 194709e45..7d78a2085 100644 --- a/crates/aether-auth/src/oauth.md +++ b/crates/aether-auth/src/oauth.md @@ -3,7 +3,7 @@ OAuth 2.0 authentication primitives for the Aether agent framework. # Architecture - [`OAuthHandler`] -- Trait implemented by consuming applications to handle OAuth UI/UX. The handler opens a browser and returns the absolute callback URL. -- [`BrowserOAuthHandler`] -- Default implementation that opens the system browser and listens on a dynamic local port. +- [`BrowserOAuthHandler`] -- Default implementation that opens the system browser and listens on a local port. - [`OAuthCredentialStorage`] -- Trait for persisting OAuth credentials keyed by provider ID, MCP server ID, or another credential key. - [`OsKeyringStore`] -- OS-keychain-backed [`OAuthCredentialStorage`] (macOS Keychain, Windows Credential Manager, Linux/FreeBSD Secret Service). Available under the `keyring` feature. - [`EncryptedFileOAuthCredentialStorage`] -- File-backed [`OAuthCredentialStorage`] that encrypts the file with [`age`](https://docs.rs/age). The passphrase is read from an environment variable. diff --git a/crates/aether-core/Cargo.toml b/crates/aether-core/Cargo.toml index a1745a6c2..1352005ca 100644 --- a/crates/aether-core/Cargo.toml +++ b/crates/aether-core/Cargo.toml @@ -53,8 +53,6 @@ testing = ["dep:tempfile", "mcp_utils/testing"] [dev-dependencies] aether-core = { package = "aether-agent-core", path = ".", features = ["testing"] } aether-doctest = { path = "../doctest" } -chrono = { workspace = true } -clap = { workspace = true } mcp-servers = { package = "aether-mcp-servers", path = "../mcp-servers", default-features = false, features = ["coding", "review"] } reqwest = { workspace = true } tempfile = { workspace = true } diff --git a/crates/aether-core/examples/anthropic_test.rs b/crates/aether-core/examples/anthropic_test.rs deleted file mode 100644 index 9f7f8cae4..000000000 --- a/crates/aether-core/examples/anthropic_test.rs +++ /dev/null @@ -1,170 +0,0 @@ -use clap::Parser; -use futures::StreamExt; -use llm::providers::anthropic::AnthropicProvider; -use llm::{ChatMessage, Context, LlmResponse, ModelSettings, ProviderFactory, StreamingModelProvider, ToolDefinition}; -use serde_json::json; -use std::error::Error; -use std::io::{self, Write}; - -#[derive(Parser)] -#[command(author, version, about = "Test Anthropic provider integration")] -struct Args { - /// The message to send to Claude - #[arg(short, long, default_value = "Hello, Claude! Can you help me write a simple Rust function?")] - prompt: String, - - /// Disable prompt caching (enabled by default) - #[arg(long)] - no_cache: bool, - - /// Claude model to use - #[arg(short = 'm', long, default_value = "claude-sonnet-4-5-20250929")] - model: String, - - /// Maximum tokens - #[arg(long, default_value = "1000")] - max_tokens: u32, - - /// Temperature (0.0 to 1.0) - #[arg(long, default_value = "0.7")] - temperature: f32, -} - -#[allow(clippy::too_many_lines)] -#[tokio::main] -async fn main() -> Result<(), Box> { - let args = Args::parse(); - - println!("🤖 Testing Anthropic Provider"); - println!("Model: {}", args.model); - println!("Message: {}", args.prompt); - println!("Caching: {}", if args.no_cache { "disabled" } else { "enabled" }); - println!("Temperature: {}", args.temperature); - println!("Max tokens: {}", args.max_tokens); - println!("{}", "=".repeat(50)); - - let provider = AnthropicProvider::from_env().await?.with_model(&args.model); - - let _ = args.no_cache; // Prompt caching is always enabled - - // Prepare context - let messages = vec![ - ChatMessage::system("You are a helpful AI assistant. Be concise but informative in your responses."), - ChatMessage::user(args.prompt), - ]; - - let tools = vec![ - ToolDefinition::new( - "search_web", - "Search the web for current information", - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search query" - } - }, - "required": ["query"] - }), - ), - ToolDefinition::new( - "calculate", - "Perform mathematical calculations", - json!({ - "type": "object", - "properties": { - "expression": { - "type": "string", - "description": "Mathematical expression to evaluate" - } - }, - "required": ["expression"] - }), - ), - ]; - let mut context = Context::new(messages, tools); - context.set_model_settings(ModelSettings { - temperature: Some(args.temperature), - max_tokens: Some(args.max_tokens), - ..Default::default() - }); - - // Stream the response - let stream = provider.stream_response(&context); - let mut stream = Box::pin(stream); - - print!("🔄 Streaming response: "); - io::stdout().flush().unwrap(); - - let mut current_tool_call = None; - let mut response_text = String::new(); - - while let Some(result) = stream.next().await { - match result? { - LlmResponse::Start => println!("\n✅ Started"), - LlmResponse::Text { chunk } => { - print!("{chunk}"); - io::stdout().flush().unwrap(); - response_text.push_str(&chunk); - } - LlmResponse::Reasoning { .. } | LlmResponse::EncryptedReasoning { .. } => {} - - LlmResponse::ToolRequestStart { id, name } => { - println!("\n🔧 Tool call started: {name} ({id})"); - current_tool_call = Some((id.clone(), name, String::new())); - } - LlmResponse::ToolRequestArg { id, chunk } => { - if let Some((ref call_id, _, ref mut args)) = current_tool_call - && call_id == &id - { - args.push_str(&chunk); - print!("."); - io::stdout().flush().unwrap(); - } - } - LlmResponse::ToolRequestComplete { tool_call } => { - println!("\n🔧 Tool call completed: {} with args: {}", tool_call.name, tool_call.arguments); - - // Simulate tool execution (you would call actual tools here) - let tool_result = match tool_call.name.as_str() { - "search_web" => "Search results: Found relevant information about Rust programming.", - "calculate" => "Calculation result: 42", - _ => "Tool executed successfully", - }; - - println!("🔧 Tool result: {tool_result}"); - current_tool_call = None; - } - LlmResponse::Done { stop_reason } => { - if let Some(reason) = stop_reason { - println!("\n✅ Stream completed ({reason:?})"); - } else { - println!("\n✅ Stream completed"); - } - break; - } - LlmResponse::Error { message } => { - println!("\n❌ Error: {message}"); - break; - } - LlmResponse::Usage { tokens } => { - println!( - "\n📊 Token usage - input: {}, output: {}, cache_read: {}, cache_creation: {}, reasoning: {}", - tokens.input_tokens, - tokens.output_tokens, - tokens.cache_read_tokens.unwrap_or_default(), - tokens.cache_creation_tokens.unwrap_or_default(), - tokens.reasoning_tokens.unwrap_or_default(), - ); - } - } - } - - println!("\n{}", "=".repeat(50)); - println!("📊 Summary:"); - println!("Total response length: {} characters", response_text.len()); - println!("✅ Test completed successfully!"); - - Ok(()) -} diff --git a/crates/aether-core/src/core/agent_builder.rs b/crates/aether-core/src/core/agent_builder.rs index afd5a7f0a..542917a24 100644 --- a/crates/aether-core/src/core/agent_builder.rs +++ b/crates/aether-core/src/core/agent_builder.rs @@ -324,18 +324,6 @@ mod tests { assert!(matches!(event.turn_outcome(), Some(TurnOutcome::Failed { .. })), "got: {event:?}"); } - #[tokio::test] - async fn system_prompt_preserves_add_order() { - let builder = AgentBuilder::new(Arc::new(llm::testing::FakeLlmProvider::new(vec![]))) - .system_prompt(Prompt::text("first")) - .system_prompt(Prompt::text("second")) - .system_prompt(Prompt::text("third")); - - let rendered = Prompt::build_all(&builder.prompts).await.unwrap(); - - assert_eq!(rendered, "first\n\nsecond\n\nthird"); - } - #[tokio::test] async fn from_spec_applies_context_window_and_model_settings() { let settings = ModelSettings { temperature: Some(0.0), max_tokens: Some(128), ..Default::default() }; diff --git a/crates/aether-core/src/core/prompt.rs b/crates/aether-core/src/core/prompt.rs index acac4377c..921ffc3cb 100644 --- a/crates/aether-core/src/core/prompt.rs +++ b/crates/aether-core/src/core/prompt.rs @@ -228,18 +228,6 @@ impl Prompt { } } - /// Resolve multiple `SystemPrompts` and join them with double newlines - pub async fn build_all(prompts: &[Prompt]) -> Result { - let mut parts = Vec::with_capacity(prompts.len()); - for p in prompts { - let part = p.build().await?; - if !part.is_empty() { - parts.push(part); - } - } - Ok(parts.join("\n\n")) - } - async fn resolve_file(path: &Path) -> Result { fs::read_to_string(path) .await @@ -368,44 +356,32 @@ mod tests { } #[tokio::test] - async fn build_all_concatenates_prompts() { - let prompts = vec![Prompt::text("Part one"), Prompt::text("Part two")]; - let result = Prompt::build_all(&prompts).await.unwrap(); - assert_eq!(result, "Part one\n\nPart two"); - } - - #[tokio::test] - async fn build_all_concatenates_multiple_files() { + async fn prompt_cache_concatenates_files() { let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("AGENTS.md"), "Agent instructions").unwrap(); - std::fs::write(dir.path().join("SYSTEM.md"), "System prompt").unwrap(); + write(dir.path().join("AGENTS.md"), "Agent instructions").unwrap(); + write(dir.path().join("SYSTEM.md"), "System prompt").unwrap(); - let prompts = vec![ + let mut cache = PromptCache::new(vec![ Prompt::file(dir.path().join("AGENTS.md"), dir.path()), Prompt::file(dir.path().join("SYSTEM.md"), dir.path()), - ]; - let result = Prompt::build_all(&prompts).await.unwrap(); - assert!(result.contains("Agent instructions")); - assert!(result.contains("System prompt")); - assert!(result.contains("\n\n")); + ]); + + assert_eq!(cache.render().await.unwrap(), "Agent instructions\n\nSystem prompt"); } #[tokio::test] - async fn build_all_skips_empty_parts() { - let prompts = vec![Prompt::text("Part one"), Prompt::text(""), Prompt::text("Part two")]; - let result = Prompt::build_all(&prompts).await.unwrap(); - assert_eq!(result, "Part one\n\nPart two"); + async fn prompt_cache_skips_empty_prompts() { + let mut cache = PromptCache::new(vec![Prompt::text("Part one"), Prompt::text(""), Prompt::text("Part two")]); + assert_eq!(cache.render().await.unwrap(), "Part one\n\nPart two"); } #[tokio::test] - async fn prompt_cache_render_matches_build_all_on_first_render() { - let prompts = vec![ - Prompt::text("first"), - Prompt::McpInstructions(instructions(&[("srv", "body")])), - Prompt::text("last"), - ]; - let expected = Prompt::build_all(&prompts).await.unwrap(); - let mut cache = PromptCache::new(prompts); + async fn prompt_cache_renders_mcp_instructions_in_place() { + let mcp = Prompt::McpInstructions(instructions(&[("srv", "body")])); + let expected = format!("first\n\n{}\n\nlast", mcp.build().await.unwrap()); + + let mut cache = PromptCache::new(vec![Prompt::text("first"), mcp, Prompt::text("last")]); + assert_eq!(cache.render().await.unwrap(), expected); } diff --git a/crates/aether-evals/src/judge.rs b/crates/aether-evals/src/judge.rs index bec96a3f0..0c322d340 100644 --- a/crates/aether-evals/src/judge.rs +++ b/crates/aether-evals/src/judge.rs @@ -108,8 +108,8 @@ pub enum JudgeError { raw_response: String, }, - #[error("judge returned invalid judgment: {reason}\nRaw response: {raw_response}")] - InvalidJudgment { reason: String, raw_response: String }, + #[error("judge returned invalid judgment: {0}")] + InvalidJudgment(String), } impl Judge { @@ -132,7 +132,7 @@ impl Judge { for criterion in response.criteria { let id = criterion.id.clone(); if responses.insert(id.clone(), criterion).is_some() { - return Err(invalid_judgment(format!("duplicate response criterion id `{id}`"), "")); + return Err(JudgeError::InvalidJudgment(format!("duplicate response criterion id `{id}`"))); } } @@ -143,13 +143,13 @@ impl Judge { for criterion in &self.criteria { let Some(response) = responses.remove(&criterion.id) else { - return Err(invalid_judgment(format!("missing response criterion `{}`", criterion.id), "")); + return Err(JudgeError::InvalidJudgment(format!("missing response criterion `{}`", criterion.id))); }; if !response.score.is_finite() || !(0.0..=1.0).contains(&response.score) { - return Err(invalid_judgment( - format!("criterion `{}` score must be between 0.0 and 1.0", criterion.id), - "", - )); + return Err(JudgeError::InvalidJudgment(format!( + "criterion `{}` score must be between 0.0 and 1.0", + criterion.id + ))); } let passed = response.score >= criterion.threshold; @@ -169,7 +169,7 @@ impl Judge { } if let Some(id) = responses.keys().next() { - return Err(invalid_judgment(format!("unknown response criterion `{id}`"), "")); + return Err(JudgeError::InvalidJudgment(format!("unknown response criterion `{id}`"))); } let weighted_score = weighted_score / total_weight; @@ -398,10 +398,6 @@ fn extract_json_object(response: &str) -> &str { } } -fn invalid_judgment(reason: String, raw_response: &str) -> JudgeError { - JudgeError::InvalidJudgment { reason, raw_response: raw_response.to_string() } -} - fn judge_response_schema() -> String { serde_json::to_string_pretty(&JudgeRubricResponse::schema()).unwrap() } @@ -611,7 +607,7 @@ mod tests { let error = judge.summarize(response).unwrap_err(); - assert!(matches!(error, JudgeError::InvalidJudgment { .. }), "response: {raw_response}"); + assert!(matches!(error, JudgeError::InvalidJudgment(_)), "response: {raw_response}"); } } From adbe19c40fcd6b3babb7198b17728a861b5a917a Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 17:16:34 -0700 Subject: [PATCH 08/26] refactor: share one TestWorkspace fixture across crates Add utils::testing::TestWorkspace behind a new `testing` feature and build every crate's temp-dir fixtures on it, replacing five near-identical implementations. - mcp-servers re-exports it in place of its own copy, drops create_test_files for TestWorkspace::files, and rebuilds CodingWorkspace on it. - aether-lspd replaces the TestProject trait and CargoProject / NodeProject with cargo_project / node_project / file_uri, and shares one remove_stale_daemon_files between TestDaemon and DaemonHarness. - aether-project's project() / home() return a TestWorkspace; the skill / settings / aether helpers become explicit paths. - wisp's attachment and dropped-file tests use it instead of ad hoc temp-file helpers. aether-lspd gains an optional aether-utils dependency for its testing feature. --- Cargo.lock | 2 +- crates/aether-lspd/Cargo.toml | 4 +- crates/aether-lspd/src/docs/testing.md | 30 ++- crates/aether-lspd/src/testing.rs | 160 ++++------------ .../aether-lspd/tests/common/cargo_project.rs | 1 - .../tests/common/daemon_harness.rs | 5 +- crates/aether-lspd/tests/common/mod.rs | 3 +- .../tests/e2e/e2e_error_handling.rs | 6 +- crates/aether-lspd/tests/e2e/e2e_lifecycle.rs | 18 +- crates/aether-lspd/tests/e2e/e2e_requests.rs | 20 +- crates/aether-lspd/tests/e2e_smoke_real.rs | 8 +- .../aether-lspd/tests/e2e_wedge_recovery.rs | 20 +- crates/aether-project/Cargo.toml | 4 +- crates/aether-project/src/aether_settings.rs | 53 +++--- crates/aether-project/src/agent_catalog.rs | 6 +- crates/aether-project/src/prompt_catalog.rs | 73 +++++--- crates/aether-project/src/prompt_file.rs | 20 +- crates/aether-project/src/testing.rs | 86 +-------- crates/mcp-servers/Cargo.toml | 2 +- crates/mcp-servers/src/testing/mod.rs | 4 +- .../tests/integration/coding_mcp_tools.rs | 32 ++-- .../tests/integration/common/mod.rs | 53 ++---- .../integration/lsp_check_errors_contract.rs | 45 +++-- .../tests/integration/lsp_diagnostics_e2e.rs | 126 ++++++------- .../tests/integration/lsp_operations_e2e.rs | 68 +++---- .../integration/lsp_ts_diagnostics_e2e.rs | 24 +-- .../integration/lsp_ts_operations_e2e.rs | 34 ++-- .../tests/integration/plugins_mcp_agents.rs | 80 ++++---- .../tests/integration/plugins_mcp_skills.rs | 35 ++-- .../tests/lsp_symbol_errors_e2e.rs | 6 +- crates/utils/Cargo.toml | 3 + crates/utils/src/lib.rs | 2 + .../workspace.rs => utils/src/testing.rs} | 44 ++--- crates/wisp/Cargo.toml | 1 + crates/wisp/src/surfaces/dropped_files.rs | 37 ++-- crates/wisp/tests/tui/attachments.rs | 171 +++++++++--------- crates/wisp/tests/tui/support.rs | 1 + 37 files changed, 530 insertions(+), 757 deletions(-) delete mode 100644 crates/aether-lspd/tests/common/cargo_project.rs rename crates/{mcp-servers/src/testing/workspace.rs => utils/src/testing.rs} (51%) diff --git a/Cargo.lock b/Cargo.lock index 5116d4fdc..273d6a4df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,6 +271,7 @@ name = "aether-lspd" version = "0.3.3" dependencies = [ "aether-lspd", + "aether-utils", "clap", "futures", "globset", @@ -365,7 +366,6 @@ dependencies = [ "schemars 1.2.2", "serde", "serde_json", - "tempfile", "thiserror 2.0.21", "tokio", "tracing", diff --git a/crates/aether-lspd/Cargo.toml b/crates/aether-lspd/Cargo.toml index 2604764e9..f385e1818 100644 --- a/crates/aether-lspd/Cargo.toml +++ b/crates/aether-lspd/Cargo.toml @@ -18,7 +18,7 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -testing = ["dep:tempfile"] +testing = ["dep:utils", "utils/testing"] [lints] workspace = true @@ -65,7 +65,7 @@ ignore = { workspace = true } thiserror = { workspace = true } # Testing support (optional) -tempfile = { workspace = true, optional = true } +utils = { package = "aether-utils", path = "../utils", version = "0.2.19", optional = true } # Unix-specific features (daemonization, signals) [target.'cfg(unix)'.dependencies] diff --git a/crates/aether-lspd/src/docs/testing.md b/crates/aether-lspd/src/docs/testing.md index e901a7437..b4102cbd9 100644 --- a/crates/aether-lspd/src/docs/testing.md +++ b/crates/aether-lspd/src/docs/testing.md @@ -1,32 +1,30 @@ -Scaffolds temporary projects for integration testing against real language servers. +Scaffolds temporary projects and in-process daemons for integration testing against language servers. Enable with the `testing` feature flag: ```toml aether-lspd = { version = "...", features = ["testing"] } ``` -# `TestProject` trait +# Projects -All test projects implement [`TestProject`], which provides: +Projects are [`TestWorkspace`]s, so they share its `file`, `write`, `path`, and `path_string` helpers and are cleaned up when dropped. -- [`root()`](TestProject::root) -- The temporary directory path. -- [`add_file(path, content)`](TestProject::add_file) -- Write a file relative to the project root. -- [`file_uri(path)`](TestProject::file_uri) -- Get an LSP `file://` URI for a relative path. -- [`file_path_str(path)`](TestProject::file_path_str) -- Get the absolute path as a string. +- [`cargo_project(name)`](cargo_project) -- A Cargo project with `Cargo.toml` and `src/main.rs`. +- [`node_project(name)`](node_project) -- A Node.js/TypeScript project with `package.json`, `tsconfig.json`, `src/index.ts`, and TypeScript 7 installed locally with `npm install`. +- [`file_uri(path)`](file_uri) -- The LSP `file://` URI for a path. -# Implementations +# Daemons -- [`CargoProject::new(name)`](CargoProject::new) -- Creates a temporary Cargo project with `Cargo.toml` and `src/main.rs`. -- [`NodeProject::new(name)`](NodeProject::new) -- Creates a temporary Node.js/TypeScript project with `package.json`, `tsconfig.json`, `src/index.ts`, and installs TypeScript 7 locally with `npm install`. - -Both are backed by [`TempDir`](tempfile::TempDir) and are automatically cleaned up when dropped. +- [`TestDaemon`] -- An in-process daemon with deterministic shutdown. +- [`remove_stale_daemon_files`] -- Clears a previous daemon's socket and lockfile. +- [`configure_fake_server`] -- Points a language at the shared fake language server. # Example ```rust,no_run -use aether_lspd::testing::{CargoProject, TestProject}; +use aether_lspd::testing::{cargo_project, file_uri}; -let project = CargoProject::new("my-test").unwrap(); -project.add_file("src/lib.rs", "pub fn hello() -> &'static str { \"hi\" }").unwrap(); -let uri = project.file_uri("src/lib.rs"); +let project = cargo_project("my-test"); +project.write("src/lib.rs", "pub fn hello() -> &'static str { \"hi\" }"); +let uri = file_uri(&project.path("src/lib.rs")); ``` diff --git a/crates/aether-lspd/src/testing.rs b/crates/aether-lspd/src/testing.rs index d81a4f926..c7d25bece 100644 --- a/crates/aether-lspd/src/testing.rs +++ b/crates/aether-lspd/src/testing.rs @@ -1,9 +1,10 @@ +#![doc = include_str!("docs/testing.md")] + use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::thread::JoinHandle; use std::time::Duration; -use tempfile::TempDir; use tokio::net::UnixStream; use tokio::sync::oneshot; @@ -11,8 +12,9 @@ use crate::LanguageId; use crate::daemon::LspDaemon; use crate::error::DaemonError; use crate::language_catalog::server_kind_for_language; -use crate::socket_path::socket_path; +use crate::socket_path::{lockfile_path, socket_path}; use crate::uri::path_to_uri; +pub use utils::testing::TestWorkspace; /// An in-process daemon with explicit configuration and deterministic shutdown. pub struct TestDaemon { @@ -26,9 +28,8 @@ impl TestDaemon { language: LanguageId, request_timeout: Duration, ) -> Result { + remove_stale_daemon_files(workspace_root, language); let socket_path = socket_path(workspace_root, language); - let _ = fs::remove_file(&socket_path); - let _ = fs::remove_file(socket_path.with_extension("lock")); let (shutdown_tx, shutdown_rx) = oneshot::channel(); let daemon_socket_path = socket_path.clone(); let task = std::thread::spawn(move || { @@ -109,148 +110,51 @@ pub unsafe fn configure_fake_server(language: LanguageId, extra_args: &[&str]) { } } -#[doc = include_str!("docs/testing.md")] -pub trait TestProject { - fn root(&self) -> &Path; - - fn add_file(&self, relative_path: &str, content: &str) -> Result { - let path = self.root().join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&path, content)?; - Ok(path) - } - - fn file_uri(&self, relative_path: &str) -> lsp_types::Uri { - path_to_uri(&self.root().join(relative_path)).expect("Invalid file path") - } - - fn file_path_str(&self, relative_path: &str) -> String { - self.root().join(relative_path).to_str().expect("Non-UTF8 path").to_string() - } -} - -/// Error type for test project operations. -#[derive(Debug, thiserror::Error)] -pub enum TestProjectError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Command '{command}' failed:\n{stderr}")] - CommandFailed { command: String, stderr: String }, -} - -/// A temporary Cargo project for testing. -pub struct CargoProject { - temp_dir: TempDir, -} - -impl TestProject for CargoProject { - fn root(&self) -> &Path { - self.temp_dir.path() - } -} - -impl CargoProject { - /// Create a new minimal Cargo project. - pub fn new(name: &str) -> Result { - let temp_dir = TempDir::new()?; - let project = Self { temp_dir }; - project.init_cargo_toml(name)?; - project.init_src_dir()?; - Ok(project) - } - - fn init_cargo_toml(&self, name: &str) -> Result<(), TestProjectError> { - let content = format!( - r#"[package] +pub fn cargo_project(name: &str) -> TestWorkspace { + let manifest = format!( + r#"[package] name = "{name}" version = "0.1.0" edition = "2021" "# - ); - fs::write(self.root().join("Cargo.toml"), content)?; - Ok(()) - } - - fn init_src_dir(&self) -> Result<(), TestProjectError> { - let src_dir = self.root().join("src"); - fs::create_dir_all(&src_dir)?; - - let main_content = r#"fn main() { + ); + let main = r#"fn main() { println!("Hello, world!"); } "#; - fs::write(src_dir.join("main.rs"), main_content)?; - Ok(()) - } + TestWorkspace::new().file("Cargo.toml", manifest).file("src/main.rs", main) } -const TYPESCRIPT_PACKAGE: &str = "typescript@7.0.2"; - -/// A temporary Node.js/TypeScript project for testing. -pub struct NodeProject { - temp_dir: TempDir, -} - -impl TestProject for NodeProject { - fn root(&self) -> &Path { - self.temp_dir.path() - } -} - -impl NodeProject { - /// Create a new minimal Node/TypeScript project. - pub fn new(name: &str) -> Result { - let temp_dir = TempDir::new()?; - let project = Self { temp_dir }; - project.init_package_json(name)?; - project.init_tsconfig()?; - project.init_src_dir()?; - project.install_typescript()?; - Ok(project) - } - - fn init_package_json(&self, name: &str) -> Result<(), TestProjectError> { - let content = format!( - r#"{{ +pub fn node_project(name: &str) -> TestWorkspace { + let package = format!( + r#"{{ "name": "{name}", "version": "0.1.0" }}"# - ); - fs::write(self.root().join("package.json"), content)?; - Ok(()) - } - - fn init_tsconfig(&self) -> Result<(), TestProjectError> { - let content = r#"{ + ); + let tsconfig = r#"{ "compilerOptions": { "strict": true, "noEmit": true } }"#; - fs::write(self.root().join("tsconfig.json"), content)?; - Ok(()) - } + let project = + TestWorkspace::new().file("package.json", package).file("tsconfig.json", tsconfig).file("src/index.ts", ""); - fn init_src_dir(&self) -> Result<(), TestProjectError> { - let src_dir = self.root().join("src"); - fs::create_dir_all(&src_dir)?; - fs::write(src_dir.join("index.ts"), "")?; - Ok(()) - } - - fn install_typescript(&self) -> Result<(), TestProjectError> { - let args = ["install", "--save-dev", "--no-audit", "--no-fund", "--prefer-offline", TYPESCRIPT_PACKAGE]; - let output = Command::new("npm").args(args).current_dir(self.root()).output()?; + let args = ["install", "--save-dev", "--no-audit", "--no-fund", "--prefer-offline", TYPESCRIPT_PACKAGE]; + let output = Command::new("npm").args(args).current_dir(project.root()).output().expect("failed to run npm"); + assert!(output.status.success(), "npm {} failed:\n{}", args.join(" "), String::from_utf8_lossy(&output.stderr)); + project +} - if !output.status.success() { - return Err(TestProjectError::CommandFailed { - command: format!("npm {}", args.join(" ")), +pub fn file_uri(path: &Path) -> lsp_types::Uri { + path_to_uri(path).expect("Invalid file path") +} - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }); - } - Ok(()) - } +pub fn remove_stale_daemon_files(workspace_root: &Path, language: LanguageId) { + let socket = socket_path(workspace_root, language); + let _ = fs::remove_file(lockfile_path(&socket)); + let _ = fs::remove_file(socket); } + +const TYPESCRIPT_PACKAGE: &str = "typescript@7.0.2"; diff --git a/crates/aether-lspd/tests/common/cargo_project.rs b/crates/aether-lspd/tests/common/cargo_project.rs deleted file mode 100644 index ebd7c4ca2..000000000 --- a/crates/aether-lspd/tests/common/cargo_project.rs +++ /dev/null @@ -1 +0,0 @@ -pub use aether_lspd::testing::{CargoProject, TestProject}; diff --git a/crates/aether-lspd/tests/common/daemon_harness.rs b/crates/aether-lspd/tests/common/daemon_harness.rs index b071113d5..025d9c7a1 100644 --- a/crates/aether-lspd/tests/common/daemon_harness.rs +++ b/crates/aether-lspd/tests/common/daemon_harness.rs @@ -1,3 +1,4 @@ +use aether_lspd::testing::remove_stale_daemon_files; use aether_lspd::{ClientError, LanguageId, LspClient, lockfile_path, socket_path}; use std::fs; use std::path::{Path, PathBuf}; @@ -24,12 +25,10 @@ pub struct DaemonHarness { impl DaemonHarness { /// Spawn a daemon for testing using `connect` pub async fn spawn(workspace_root: &Path, language: LanguageId) -> Result { + remove_stale_daemon_files(workspace_root, language); let sock_path = socket_path(workspace_root, language); let lock_path = lockfile_path(&sock_path); - let _ = fs::remove_file(&sock_path); - let _ = fs::remove_file(&lock_path); - let _client = LspClient::connect(workspace_root, language).await.map_err(|e| HarnessError::SpawnFailed(e.to_string()))?; diff --git a/crates/aether-lspd/tests/common/mod.rs b/crates/aether-lspd/tests/common/mod.rs index c236d4485..a75101466 100644 --- a/crates/aether-lspd/tests/common/mod.rs +++ b/crates/aether-lspd/tests/common/mod.rs @@ -1,9 +1,8 @@ #![allow(dead_code, unused_imports)] -pub mod cargo_project; pub mod daemon_harness; -pub use cargo_project::{CargoProject, TestProject}; +pub use aether_lspd::testing::{cargo_project, file_uri}; pub use daemon_harness::DaemonHarness; use aether_lspd::testing::configure_fake_server; diff --git a/crates/aether-lspd/tests/e2e/e2e_error_handling.rs b/crates/aether-lspd/tests/e2e/e2e_error_handling.rs index a02e38802..eadd8053f 100644 --- a/crates/aether-lspd/tests/e2e/e2e_error_handling.rs +++ b/crates/aether-lspd/tests/e2e/e2e_error_handling.rs @@ -1,11 +1,11 @@ -use crate::common::{CargoProject, DaemonHarness, TestProject, use_fake_servers}; +use crate::common::{DaemonHarness, cargo_project, file_uri, use_fake_servers}; use aether_lspd::LanguageId; #[tokio::test] async fn daemon_survives_client_drop() { use_fake_servers(); - let project = CargoProject::new("client_drop").expect("Failed to create project"); + let project = cargo_project("client_drop"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); { @@ -13,7 +13,7 @@ async fn daemon_survives_client_drop() { } let client = harness.connect().await.expect("Failed to reconnect to daemon"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); let hover = client.hover(uri, 0, 0).await.expect("Hover failed"); assert!(hover.is_some()); diff --git a/crates/aether-lspd/tests/e2e/e2e_lifecycle.rs b/crates/aether-lspd/tests/e2e/e2e_lifecycle.rs index 19e22665f..dd3308d54 100644 --- a/crates/aether-lspd/tests/e2e/e2e_lifecycle.rs +++ b/crates/aether-lspd/tests/e2e/e2e_lifecycle.rs @@ -1,5 +1,5 @@ use crate::common::{ - CargoProject, DaemonHarness, TestProject, hover_text, poll_workspace_diagnostics, use_fake_servers, + DaemonHarness, cargo_project, file_uri, hover_text, poll_workspace_diagnostics, use_fake_servers, workspace_error_count, }; use aether_lspd::{LanguageId, LspClient, lockfile_path, socket_path}; @@ -9,7 +9,7 @@ use std::time::Duration; async fn daemon_persists_after_client_disconnect() { use_fake_servers(); - let project = CargoProject::new("disconnect_persists").expect("Failed to create project"); + let project = cargo_project("disconnect_persists"); let socket_path = socket_path(project.root(), LanguageId::Rust); let lockfile_path = lockfile_path(&socket_path); @@ -23,13 +23,13 @@ async fn daemon_persists_after_client_disconnect() { async fn multiple_clients_share_fake_server_session() { use_fake_servers(); - let project = CargoProject::new("shared_session").expect("Failed to create project"); + let project = cargo_project("shared_session"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client1 = harness.connect().await.expect("Failed to connect client 1"); let client2 = harness.connect().await.expect("Failed to connect client 2"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); let hover1 = hover_text(client1.hover(uri.clone(), 0, 0).await.expect("Hover failed")); let hover2 = hover_text(client2.hover(uri.clone(), 0, 0).await.expect("Hover failed")); @@ -44,13 +44,13 @@ async fn multiple_clients_share_fake_server_session() { async fn diagnostics_are_available_across_clients_without_explicit_open() { use_fake_servers(); - let project = CargoProject::new("shared_diagnostics").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() { let error = 1; }\n").expect("Failed to add source file"); + let project = cargo_project("shared_diagnostics"); + project.write("src/main.rs", "fn main() { let error = 1; }\n"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client1 = harness.connect().await.expect("Failed to connect client 1"); let client2 = harness.connect().await.expect("Failed to connect client 2"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); let diagnostics1 = client1.get_diagnostics(Some(uri.clone())).await.expect("Failed to get diagnostics"); let diagnostics2 = client2.get_diagnostics(Some(uri.clone())).await.expect("Failed to get diagnostics"); @@ -97,8 +97,8 @@ async fn workspace_bootstrap_diagnostics_are_available_without_explicit_open() { async fn workspace_diagnostics_refresh_after_external_edit_without_explicit_open() { use_fake_servers(); - let project = CargoProject::new("workspace_external_refresh").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() { let ok = 1; }\n").expect("Failed to add source file"); + let project = cargo_project("workspace_external_refresh"); + project.write("src/main.rs", "fn main() { let ok = 1; }\n"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect client"); diff --git a/crates/aether-lspd/tests/e2e/e2e_requests.rs b/crates/aether-lspd/tests/e2e/e2e_requests.rs index b1af25824..38352f8ee 100644 --- a/crates/aether-lspd/tests/e2e/e2e_requests.rs +++ b/crates/aether-lspd/tests/e2e/e2e_requests.rs @@ -1,4 +1,4 @@ -use crate::common::{CargoProject, DaemonHarness, TestProject, hover_text, use_fake_servers}; +use crate::common::{DaemonHarness, cargo_project, file_uri, hover_text, use_fake_servers}; use aether_lspd::LanguageId; use lsp_types::{DocumentSymbolResponse, GotoDefinitionResponse}; use std::path::PathBuf; @@ -7,7 +7,7 @@ use std::path::PathBuf; async fn request_helpers_round_trip_through_fake_server() { use_fake_servers(); - let project = CargoProject::new("request_contracts").expect("Failed to create project"); + let project = cargo_project("request_contracts"); let content = r#"fn example_fn() -> i32 { 7 } @@ -17,11 +17,11 @@ fn main() { println!("{}", value); } "#; - project.add_file("src/main.rs", content).expect("Failed to add file"); + project.write("src/main.rs", content); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); let hover = hover_text(client.hover(uri.clone(), 0, 0).await.expect("Hover request failed")); assert!(hover.contains("open_count=1")); @@ -68,12 +68,12 @@ fn main() { async fn documents_in_paths_with_spaces_round_trip_through_the_daemon() { use_fake_servers(); - let project = CargoProject::new("request_spaces").expect("Failed to create project"); - project.add_file("src/my mod.rs", "fn main() { let error = 1; }\n").expect("Failed to add file"); + let project = cargo_project("request_spaces"); + project.write("src/my mod.rs", "fn main() { let error = 1; }\n"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect"); - let uri = project.file_uri("src/my mod.rs"); + let uri = file_uri(&project.path("src/my mod.rs")); assert!(uri.as_str().contains("my%20mod.rs"), "URI must be percent-encoded: {}", uri.as_str()); @@ -96,12 +96,12 @@ async fn documents_in_paths_with_spaces_round_trip_through_the_daemon() { async fn diagnostic_helpers_round_trip_through_fake_server() { use_fake_servers(); - let project = CargoProject::new("request_diagnostics").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() { let error = 1; }\n").expect("Failed to add file"); + let project = cargo_project("request_diagnostics"); + project.write("src/main.rs", "fn main() { let error = 1; }\n"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); client.queue_diagnostic_refresh(uri.clone()).await.expect("Queue diagnostic refresh failed"); diff --git a/crates/aether-lspd/tests/e2e_smoke_real.rs b/crates/aether-lspd/tests/e2e_smoke_real.rs index e40e933eb..87886c710 100644 --- a/crates/aether-lspd/tests/e2e_smoke_real.rs +++ b/crates/aether-lspd/tests/e2e_smoke_real.rs @@ -1,18 +1,18 @@ mod common; use aether_lspd::LanguageId; -use common::{CargoProject, DaemonHarness, TestProject, hover_text}; +use common::{DaemonHarness, cargo_project, file_uri, hover_text}; #[tokio::test] #[ignore = "requires rust-analyzer on PATH"] async fn rust_analyzer_smoke_hover() { - let project = CargoProject::new("smoke_ra").expect("Failed to create project"); + let project = cargo_project("smoke_ra"); let content = "fn main() { let value = 1; }\n"; - project.add_file("src/main.rs", content).expect("Failed to add source file"); + project.write("src/main.rs", content); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect client"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); let hover = hover_text(client.hover(uri, 0, 0).await.expect("Hover failed")); assert_ne!(hover, ""); diff --git a/crates/aether-lspd/tests/e2e_wedge_recovery.rs b/crates/aether-lspd/tests/e2e_wedge_recovery.rs index 7427ea0dc..b96a0ad1a 100644 --- a/crates/aether-lspd/tests/e2e_wedge_recovery.rs +++ b/crates/aether-lspd/tests/e2e_wedge_recovery.rs @@ -8,7 +8,7 @@ mod common; use aether_lspd::testing::configure_fake_server; use aether_lspd::{ClientError, LSP_REQUEST_TIMED_OUT, LanguageId}; -use common::{CargoProject, DaemonHarness, TestProject, hover_text}; +use common::{DaemonHarness, cargo_project, file_uri, hover_text}; use std::sync::Once; static SETUP: Once = Once::new(); @@ -27,12 +27,12 @@ fn use_misbehaving_fake_server() { async fn wedged_request_fails_with_timeout_instead_of_hanging() { use_misbehaving_fake_server(); - let project = CargoProject::new("wedge_timeout").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("wedge_timeout"); + project.write("src/main.rs", "fn main() {}\n"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect"); - let result = client.goto_definition(project.file_uri("src/main.rs"), 0, 3).await; + let result = client.goto_definition(file_uri(&project.path("src/main.rs")), 0, 3).await; match result { Err(ClientError::LspError { code, message }) => { assert_eq!(code, LSP_REQUEST_TIMED_OUT, "unexpected error: {message}"); @@ -47,11 +47,11 @@ async fn wedged_request_fails_with_timeout_instead_of_hanging() { async fn wedged_server_is_replaced_on_next_request() { use_misbehaving_fake_server(); - let project = CargoProject::new("wedge_recovery").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("wedge_recovery"); + project.write("src/main.rs", "fn main() {}\n"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); let wedged = client.goto_definition(uri.clone(), 0, 3).await; assert!(wedged.is_err(), "wedged request should fail, got {wedged:?}"); @@ -66,11 +66,11 @@ async fn wedged_server_is_replaced_on_next_request() { async fn crashed_server_is_replaced_on_next_request() { use_misbehaving_fake_server(); - let project = CargoProject::new("crash_recovery").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("crash_recovery"); + project.write("src/main.rs", "fn main() {}\n"); let harness = DaemonHarness::spawn(project.root(), LanguageId::Rust).await.expect("Failed to spawn daemon"); let client = harness.connect().await.expect("Failed to connect"); - let uri = project.file_uri("src/main.rs"); + let uri = file_uri(&project.path("src/main.rs")); let crashed = client.find_references(uri.clone(), 0, 0, true).await; assert!(crashed.is_err(), "request to crashing server should fail, got {crashed:?}"); diff --git a/crates/aether-project/Cargo.toml b/crates/aether-project/Cargo.toml index f781ad23f..b7c1f4711 100644 --- a/crates/aether-project/Cargo.toml +++ b/crates/aether-project/Cargo.toml @@ -30,13 +30,11 @@ noyalib = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } globset = { workspace = true } -tempfile = { workspace = true, optional = true } [features] -testing = ["dep:tempfile"] +testing = ["utils/testing"] [dev-dependencies] aether-project = { package = "aether-project", path = ".", features = ["testing"] } aether-doctest = { path = "../doctest" } -tempfile = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } diff --git a/crates/aether-project/src/aether_settings.rs b/crates/aether-project/src/aether_settings.rs index 8ad210bd3..5ede9ad9b 100644 --- a/crates/aether-project/src/aether_settings.rs +++ b/crates/aether-project/src/aether_settings.rs @@ -751,8 +751,9 @@ mod tests { #[test] fn load_default_merges_user_and_project_settings_with_project_winning() { let project = project(); - let home = home().settings( - &json!({ + let home = home().file( + ".aether/settings.json", + json!({ "agent": "shared", "prompts": ["USER.md"], "agents": [agent_json("shared", "User shared"), agent_json("user-only", "User only")] @@ -761,7 +762,7 @@ mod tests { ); project.write( ".aether/settings.json", - &json!({ + json!({ "agent": "project-only", "prompts": ["PROJECT.md"], "agents": [agent_json("shared", "Project shared"), agent_json("project-only", "Project only")] @@ -769,7 +770,7 @@ mod tests { .to_string(), ); - let aether_home = home.aether(); + let aether_home = home.path(".aether"); let config = load_default_from_home(project.root(), &aether_home).unwrap(); assert_eq!( config, @@ -789,9 +790,10 @@ mod tests { #[test] fn load_default_uses_user_settings_when_project_settings_are_missing() { let project = project(); - let home = home().settings(&json!({ "agents": [agent_json("user-only", "User only")] }).to_string()); + let home = home() + .file(".aether/settings.json", json!({ "agents": [agent_json("user-only", "User only")] }).to_string()); - let aether_home = home.aether(); + let aether_home = home.path(".aether"); let config = load_default_from_home(project.root(), &aether_home).unwrap(); assert_eq!( config, @@ -804,9 +806,10 @@ mod tests { let project = project(); let home = home() .file(".aether/agents/user.md", "User instructions") - .file(".aether/mcp/user.json", &mcp_json("user")) - .settings( - &json!({ + .file(".aether/mcp/user.json", mcp_json("user")) + .file( + ".aether/settings.json", + json!({ "agents": [agent_json_with( "user-only", "User only", @@ -816,7 +819,7 @@ mod tests { .to_string(), ); - let aether_home = home.aether(); + let aether_home = home.path(".aether"); let config = load_default_from_home(project.root(), &aether_home).unwrap(); let catalog = AgentCatalog::from_settings(project.root(), config).unwrap(); let spec = catalog.resolve("user-only").unwrap(); @@ -834,10 +837,10 @@ mod tests { let home = home(); let project = project().file( ".aether/settings.json", - &json!({ "agents": [agent_json("project-only", "Project only")] }).to_string(), + json!({ "agents": [agent_json("project-only", "Project only")] }).to_string(), ); - let aether_home = home.aether(); + let aether_home = home.path(".aether"); let config = load_default_from_home(project.root(), &aether_home).unwrap(); assert_eq!( @@ -853,7 +856,7 @@ mod tests { fn load_default_returns_default_when_user_and_project_settings_are_missing() { let project = project(); let home = home(); - let aether_home = home.aether(); + let aether_home = home.path(".aether"); let config = load_default_from_home(project.root(), &aether_home).unwrap(); assert_eq!(config, AetherSettings::default()); } @@ -861,8 +864,8 @@ mod tests { #[test] fn load_default_rejects_malformed_user_settings() { let project = project(); - let home = home().settings("{not-json"); - let aether_home = home.aether(); + let home = home().file(".aether/settings.json", "{not-json"); + let aether_home = home.path(".aether"); let err = load_default_from_home(project.root(), &aether_home).unwrap_err(); assert!(matches!(err, SettingsError::ParseError(_))); } @@ -1057,9 +1060,10 @@ mod tests { #[test] fn load_default_resolves_workspace_scoped_user_prompt_and_mcp_paths() { - let project = project().file("AGENTS.md", "Agent instructions").file(".aether/mcp.json", &mcp_json("project")); - let home = home().file(".aether/agents/planner/SYSTEM.md", "System instructions").settings( - &json!({ + let project = project().file("AGENTS.md", "Agent instructions").file(".aether/mcp.json", mcp_json("project")); + let home = home().file(".aether/agents/planner/SYSTEM.md", "System instructions").file( + ".aether/settings.json", + json!({ "agents": [agent_json_with( "planner", "Plans work", @@ -1075,7 +1079,7 @@ mod tests { .to_string(), ); - let aether_home = home.aether(); + let aether_home = home.path(".aether"); let config = load_default_from_home(project.root(), &aether_home).unwrap(); let catalog = AgentCatalog::from_settings(project.root(), config).unwrap(); let spec = catalog.resolve("planner").unwrap(); @@ -1097,7 +1101,7 @@ mod tests { fn workspace_scoped_paths_expand_in_project_settings_without_absolutizing_normal_relative_paths() { let project = project().file("PROJECT.md", "Project prompt").file("AGENTS.md", "Agent prompt").file( ".aether/settings.json", - &json!({ + json!({ "agents": [agent_json_with( "alpha", "Alpha", @@ -1220,7 +1224,7 @@ mod tests { #[test] fn optional_existing_mcp_source_preserves_defer_tools_flag() { - let project = project().file("BASE.md", "Base instructions").file("mcp.json", &mcp_json("lazy")); + let project = project().file("BASE.md", "Base instructions").file("mcp.json", mcp_json("lazy")); let config = AetherSettings { agents: vec![AgentConfig { prompts: vec![PromptSource::file("BASE.md")], @@ -1297,8 +1301,9 @@ mod tests { #[test] fn user_settings_relative_paths_absolutize_at_load_but_workspace_token_is_preserved() { let project = project().file("AGENTS.md", "agents"); - let home = home().file(".aether/agents/planner/SYSTEM.md", "system").settings( - &json!({ + let home = home().file(".aether/agents/planner/SYSTEM.md", "system").file( + ".aether/settings.json", + json!({ "agents": [agent_json_with( "planner", "Plans", @@ -1308,7 +1313,7 @@ mod tests { .to_string(), ); - let aether_home = home.aether(); + let aether_home = home.path(".aether"); let settings = load_default_from_home(project.root(), &aether_home).unwrap(); let expected_user = aether_home.join("agents/planner/SYSTEM.md").to_string_lossy().to_string(); diff --git a/crates/aether-project/src/agent_catalog.rs b/crates/aether-project/src/agent_catalog.rs index 9b601be94..2078320e9 100644 --- a/crates/aether-project/src/agent_catalog.rs +++ b/crates/aether-project/src/agent_catalog.rs @@ -559,7 +559,7 @@ mod tests { #[test] fn top_level_mcps_are_inherited_when_agent_mcps_are_empty() { - let dir = project().file("BASE.md", "Base instructions").file("base-mcp.json", &mcp_json("base")); + let dir = project().file("BASE.md", "Base instructions").file("base-mcp.json", mcp_json("base")); let config = AetherSettings { prompts: vec![crate::PromptSource::file("BASE.md")], @@ -578,8 +578,8 @@ mod tests { fn agent_mcps_override_top_level_mcps() { let dir = project() .file("BASE.md", "Base instructions") - .file("base-mcp.json", &mcp_json("base")) - .file("agent-mcp.json", &mcp_json("agent")); + .file("base-mcp.json", mcp_json("base")) + .file("agent-mcp.json", mcp_json("agent")); let config = AetherSettings { prompts: vec![crate::PromptSource::file("BASE.md")], diff --git a/crates/aether-project/src/prompt_catalog.rs b/crates/aether-project/src/prompt_catalog.rs index 1dad57f35..981066dca 100644 --- a/crates/aether-project/src/prompt_catalog.rs +++ b/crates/aether-project/src/prompt_catalog.rs @@ -137,8 +137,8 @@ mod tests { #[test] fn discover_user_only_prompt() { - let dir = project().skill( - "commit", + let dir = project().file( + "commit/SKILL.md", "---\ndescription: Generate commit messages\nuser-invocable: true\nagent-invocable: false\n---\nGenerate a commit message.", ); @@ -154,8 +154,10 @@ mod tests { #[test] fn discover_agent_only_prompt() { - let dir = project() - .skill("explain-code", "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code."); + let dir = project().file( + "explain-code/SKILL.md", + "---\ndescription: Explain code\nagent-invocable: true\n---\nExplain the code.", + ); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); assert_eq!(catalog.all().len(), 1); @@ -167,8 +169,8 @@ mod tests { #[test] fn discover_rule_only_prompt() { - let dir = project().skill( - "rust-rules", + let dir = project().file( + "rust-rules/SKILL.md", "---\ndescription: Rust conventions\nagent-invocable: false\ntriggers:\n read:\n - \"crates/**/*.rs\"\n---\nFollow Rust conventions.", ); @@ -185,8 +187,8 @@ mod tests { #[test] fn discover_dual_use_prompt() { - let dir = project().skill( - "explain", + let dir = project().file( + "explain/SKILL.md", "---\ndescription: Explain code\nuser-invocable: true\nagent-invocable: true\nargument-hint: \"[path]\"\n---\nExplain with diagrams.", ); @@ -206,8 +208,8 @@ mod tests { fn reject_duplicate_names() { let dir = project() // Second skill has an explicit name override so it collides with "foo" - .skill("foo", "---\ndescription: First\nuser-invocable: true\n---\nContent.") - .skill("bar", "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent."); + .file("foo/SKILL.md", "---\ndescription: First\nuser-invocable: true\n---\nContent.") + .file("bar/SKILL.md", "---\nname: foo\ndescription: Second\nuser-invocable: true\n---\nContent."); let result = PromptCatalog::from_dir(dir.root()); assert!(matches!(result, Err(SettingsError::DuplicatePromptName { .. }))); @@ -215,7 +217,7 @@ mod tests { #[test] fn empty_description_defaults_to_name() { - let dir = project().skill("bad", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent."); + let dir = project().file("bad/SKILL.md", "---\ndescription: \"\"\nuser-invocable: true\n---\nContent."); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); assert_eq!(catalog.all().len(), 1); @@ -224,7 +226,7 @@ mod tests { #[test] fn skill_without_activation_surface_defaults_to_user_invocable() { - let dir = project().skill("noop", "---\ndescription: Does nothing\n---\nContent."); + let dir = project().file("noop/SKILL.md", "---\ndescription: Does nothing\n---\nContent."); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); assert_eq!(catalog.all().len(), 1); @@ -241,7 +243,8 @@ mod tests { #[test] fn name_defaults_to_directory_name() { - let dir = project().skill("my-skill", "---\ndescription: My skill\nagent-invocable: true\n---\nContent."); + let dir = + project().file("my-skill/SKILL.md", "---\ndescription: My skill\nagent-invocable: true\n---\nContent."); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); assert_eq!(catalog.all()[0].name, "my-skill"); @@ -249,8 +252,10 @@ mod tests { #[test] fn name_from_frontmatter_overrides_directory() { - let dir = project() - .skill("dir-name", "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent."); + let dir = project().file( + "dir-name/SKILL.md", + "---\nname: custom-name\ndescription: Custom\nuser-invocable: true\n---\nContent.", + ); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); assert_eq!(catalog.all()[0].name, "custom-name"); @@ -259,12 +264,15 @@ mod tests { #[test] fn matching_read_rules_finds_matches() { let dir = project() - .skill( - "rust-rules", + .file( + "rust-rules/SKILL.md", "---\ndescription: Rust rules\ntriggers:\n read:\n - \"src/**/*.rs\"\n---\nRust rules.", ) - .skill("ts-rules", "---\ndescription: TS rules\ntriggers:\n read:\n - \"src/**/*.ts\"\n---\nTS rules.") - .skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit."); + .file( + "ts-rules/SKILL.md", + "---\ndescription: TS rules\ntriggers:\n read:\n - \"src/**/*.ts\"\n---\nTS rules.", + ) + .file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit."); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); let matches = catalog.matching_rules("src/main.rs"); @@ -295,8 +303,8 @@ mod tests { #[test] fn skips_hidden_directories() { let dir = project() - .skill(".archived", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.") - .skill("visible", "---\ndescription: Visible\nuser-invocable: true\n---\nNew."); + .file(".archived/SKILL.md", "---\ndescription: Archived\nuser-invocable: true\n---\nOld.") + .file("visible/SKILL.md", "---\ndescription: Visible\nuser-invocable: true\n---\nNew."); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); assert_eq!(catalog.all().len(), 1); @@ -305,8 +313,8 @@ mod tests { #[test] fn preserves_tags_and_metadata() { - let dir = project().skill( - "tagged", + let dir = project().file( + "tagged/SKILL.md", "---\ndescription: Tagged skill\nagent-invocable: true\ntags:\n - rust\n - testing\nagent_authored: true\nhelpful: 5\nharmful: 1\n---\nContent.", ); @@ -320,8 +328,10 @@ mod tests { #[test] fn from_dirs_last_wins() { - let dir_a = project().skill("rust", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A."); - let dir_b = project().skill("rust", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B."); + let dir_a = + project().file("rust/SKILL.md", "---\ndescription: Rust A\nagent-invocable: true\n---\nFrom dir A."); + let dir_b = + project().file("rust/SKILL.md", "---\ndescription: Rust B\nagent-invocable: true\n---\nFrom dir B."); let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]); assert_eq!(catalog.all().len(), 1); @@ -334,8 +344,10 @@ mod tests { #[test] fn from_dirs_union() { - let dir_a = project().skill("rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content."); - let dir_b = project().skill("python", "---\ndescription: Python\nagent-invocable: true\n---\nPython content."); + let dir_a = + project().file("rust/SKILL.md", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content."); + let dir_b = + project().file("python/SKILL.md", "---\ndescription: Python\nagent-invocable: true\n---\nPython content."); let catalog = PromptCatalog::from_dirs(&[dir_a.root().to_path_buf(), dir_b.root().to_path_buf()]); assert_eq!(catalog.all().len(), 2); @@ -347,7 +359,8 @@ mod tests { #[test] fn from_dirs_skips_missing() { - let dir_a = project().skill("rust", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content."); + let dir_a = + project().file("rust/SKILL.md", "---\ndescription: Rust\nagent-invocable: true\n---\nRust content."); let missing = PathBuf::from("/tmp/nonexistent-skills-dir-12345"); let catalog = PromptCatalog::from_dirs(&[missing, dir_a.root().to_path_buf()]); @@ -388,7 +401,7 @@ mod tests { #[test] fn discover_mixed_skill_md_and_flat_rules() { let dir = project() - .skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.") + .file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit message.") .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions."); let catalog = PromptCatalog::from_dir(dir.root()).unwrap(); @@ -401,7 +414,7 @@ mod tests { #[test] fn from_dirs_merges_flat_rules() { - let dir_a = project().skill("commit", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit."); + let dir_a = project().file("commit/SKILL.md", "---\ndescription: Commit\nuser-invocable: true\n---\nCommit."); let dir_b = project() .file("rust-rules.md", "---\ndescription: Rust rules\nglobs:\n - \"**/*.rs\"\n---\nRust conventions."); diff --git a/crates/aether-project/src/prompt_file.rs b/crates/aether-project/src/prompt_file.rs index 189ddad15..622fa5a83 100644 --- a/crates/aether-project/src/prompt_file.rs +++ b/crates/aether-project/src/prompt_file.rs @@ -261,8 +261,8 @@ mod tests { #[test] fn parse_skill_frontmatter_and_body() { - let dir = project().skill( - "my-skill", + let dir = project().file( + "my-skill/SKILL.md", "---\ndescription: Test skill\ntags:\n - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.", ); @@ -279,7 +279,7 @@ mod tests { #[test] fn backward_compat_old_frontmatter() { - let dir = project().skill("old-skill", "---\ndescription: An old skill\n---\nBody."); + let dir = project().file("old-skill/SKILL.md", "---\ndescription: An old skill\n---\nBody."); let parsed = PromptFile::parse(&dir.root().join("old-skill").join(SKILL_FILENAME)).unwrap(); assert_eq!(parsed.description, "An old skill"); @@ -398,8 +398,8 @@ mod tests { #[test] fn parse_globs_key() { - let dir = project().skill( - "ts-conventions", + let dir = project().file( + "ts-conventions/SKILL.md", "---\ndescription: TS conventions\nglobs:\n - \"src/**/*.ts\"\n - \"src/**/*.tsx\"\n---\nUse strict TypeScript.", ); @@ -411,8 +411,8 @@ mod tests { #[test] fn parse_paths_key() { - let dir = project().skill( - "rust-rules", + let dir = project().file( + "rust-rules/SKILL.md", "---\ndescription: Rust rules\npaths:\n - \"**/*.rs\"\n---\nFollow Rust conventions.", ); @@ -423,8 +423,8 @@ mod tests { #[test] fn parse_merges_all_glob_sources() { - let dir = project().skill( - "merged-rules", + let dir = project().file( + "merged-rules/SKILL.md", "---\ndescription: Merged\ntriggers:\n read:\n - \"src/**/*.rs\"\nglobs:\n - \"lib/**/*.ts\"\npaths:\n - \"app/**/*.py\"\n---\nMerged rules.", ); @@ -466,7 +466,7 @@ mod tests { #[test] fn skill_file_defaults_user_invocable_true_when_missing() { - let dir = project().skill("compat-skill", "---\ndescription: Claude-style skill\n---\nSkill body."); + let dir = project().file("compat-skill/SKILL.md", "---\ndescription: Claude-style skill\n---\nSkill body."); let parsed = PromptFile::parse(&dir.root().join("compat-skill").join(SKILL_FILENAME)).unwrap(); assert!(parsed.user_invocable); diff --git a/crates/aether-project/src/testing.rs b/crates/aether-project/src/testing.rs index bde7dcb50..02794b537 100644 --- a/crates/aether-project/src/testing.rs +++ b/crates/aether-project/src/testing.rs @@ -3,33 +3,30 @@ //! Compiled for the crate's own tests and for any consumer that enables the //! `testing` feature. Prefer these over hand-rolling per-suite fixtures. -use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use serde_json::{Value, json}; -use tempfile::TempDir; use crate::prompt_file::{PromptFile, PromptTriggers}; -use crate::{AgentConfig, PromptSource, SKILL_FILENAME}; +use crate::{AgentConfig, PromptSource}; use aether_core::agent_spec::{AgentSpec, AgentSpecExposure}; use aether_core::mcp::{McpConfig, ToolFilter}; use llm::{ModelSettings, ProviderConnectionOverrides}; +pub use utils::testing::TestWorkspace; /// The model id every shared agent fixture resolves to. pub const DEFAULT_MODEL: &str = "anthropic:claude-sonnet-4-5"; -/// A temporary project directory for tests that need on-disk settings, prompts, or skills. pub fn mcp_json(server: &str) -> String { json!({ "servers": { server: { "command": format!("{server}-server") } } }).to_string() } -pub fn project() -> TestProject { - TestProject { root: temp_dir("project") } +pub fn project() -> TestWorkspace { + TestWorkspace::new() } -/// A temporary user home directory for tests that exercise `AETHER_HOME` lookups. -pub fn home() -> TestHome { - TestHome { root: temp_dir("home") } +pub fn home() -> TestWorkspace { + TestWorkspace::new() } /// A minimal user-invocable [`AgentConfig`] as it would be parsed from `settings.json`. @@ -108,62 +105,6 @@ pub fn fake_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec { } } -/// A temporary project directory whose files are set up fluently. -pub struct TestProject { - root: TempDir, -} - -impl TestProject { - /// The project root path. - pub fn root(&self) -> &Path { - self.root.path() - } - - /// Writes `content` to `path` relative to the project root, creating parent directories. - pub fn file(self, path: &str, content: &str) -> Self { - self.write(path, content); - self - } - - /// Writes `content` to `/SKILL.md`, creating the skill directory. - pub fn skill(self, name: &str, content: &str) -> Self { - self.file(&format!("{name}/{SKILL_FILENAME}"), content) - } - - /// Writes a file after the fixture exists, e.g. to exercise a state transition. - pub fn write(&self, path: &str, content: &str) { - write_file(self.root.path(), path, content); - } -} - -/// A temporary home directory whose `.aether` contents are set up fluently. -pub struct TestHome { - root: TempDir, -} - -impl TestHome { - /// The `.aether` directory inside this home. - pub fn aether(&self) -> PathBuf { - self.root.path().join(".aether") - } - - /// The home root path. - pub fn root(&self) -> &Path { - self.root.path() - } - - /// Writes `content` to `path` relative to the home root, creating parent directories. - pub fn file(self, path: &str, content: &str) -> Self { - write_file(self.root.path(), path, content); - self - } - - /// Writes user settings to `.aether/settings.json`. - pub fn settings(self, json: &str) -> Self { - self.file(".aether/settings.json", json) - } -} - /// A fluent builder for test [`PromptFile`]s; see [`prompt_file`]. pub struct PromptFileBuilder { file: PromptFile, @@ -231,16 +172,3 @@ impl PromptFileBuilder { self.file } } - -fn temp_dir(label: &str) -> TempDir { - TempDir::new().unwrap_or_else(|error| panic!("failed to create temp {label} dir: {error}")) -} - -fn write_file(root: &Path, path: &str, content: &str) { - let full_path = root.join(path); - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent).expect("failed to create fixture parent directories"); - } - - fs::write(full_path, content).expect("failed to write fixture file"); -} diff --git a/crates/mcp-servers/Cargo.toml b/crates/mcp-servers/Cargo.toml index 52fce6f1a..bdee5cef4 100644 --- a/crates/mcp-servers/Cargo.toml +++ b/crates/mcp-servers/Cargo.toml @@ -26,7 +26,7 @@ workspace = true [features] default = ["coding", "skills", "tasks", "subagents", "review"] -test-helpers = ["dep:tempfile"] +test-helpers = ["utils/testing"] file-ops = [] coding = [ "file-ops", diff --git a/crates/mcp-servers/src/testing/mod.rs b/crates/mcp-servers/src/testing/mod.rs index 420f782e8..a89596a02 100644 --- a/crates/mcp-servers/src/testing/mod.rs +++ b/crates/mcp-servers/src/testing/mod.rs @@ -9,12 +9,10 @@ mod web_fetch; #[cfg(feature = "tasks")] mod task_store; -mod workspace; - #[cfg(feature = "coding")] pub use web_fetch::FakeHttpClient; #[cfg(feature = "tasks")] pub use task_store::TestTaskStore; -pub use workspace::TestWorkspace; +pub use utils::testing::TestWorkspace; diff --git a/crates/mcp-servers/tests/integration/coding_mcp_tools.rs b/crates/mcp-servers/tests/integration/coding_mcp_tools.rs index 372300174..0c79009d3 100644 --- a/crates/mcp-servers/tests/integration/coding_mcp_tools.rs +++ b/crates/mcp-servers/tests/integration/coding_mcp_tools.rs @@ -12,8 +12,8 @@ use utils::tool_result_truncator::saved_path; #[tokio::test] async fn read_file_supports_paging_and_snake_case_arguments() -> TestResult { - let workspace = CodingWorkspace::new().await?; - let path = workspace.write("notes.txt", "line 1\nline 2\nline 3\nline 4\nline 5")?; + let workspace = CodingWorkspace::new().await; + let path = workspace.write("notes.txt", "line 1\nline 2\nline 3\nline 4\nline 5"); let result = workspace.client.call("read_file", serde_json::json!({ "file_path": path, "offset": 2, "limit": 2 })).await?; assert_eq!(result["status"], "success"); @@ -26,8 +26,8 @@ async fn read_file_supports_paging_and_snake_case_arguments() -> TestResult { #[tokio::test] async fn read_file_truncates_long_lines() -> TestResult { - let workspace = CodingWorkspace::new().await?; - let long_path = workspace.write("long.txt", &format!("short\n{}", "x".repeat(2500)))?; + let workspace = CodingWorkspace::new().await; + let long_path = workspace.write("long.txt", &format!("short\n{}", "x".repeat(2500))); let result = workspace .client .call("read_file", ReadFileArgs { file_path: long_path.to_string_lossy().into(), ..Default::default() }) @@ -38,9 +38,9 @@ async fn read_file_truncates_long_lines() -> TestResult { #[tokio::test] async fn full_read_file_pages_reach_the_llm_whole() -> TestResult { - let workspace = CodingWorkspace::new().await?; + let workspace = CodingWorkspace::new().await; let content = (1..=5_000).map(|n| format!("let line_{n} = \"{}\";", "x".repeat(20))).collect::>(); - let path = workspace.write("big.rs", &content.join("\n"))?; + let path = workspace.write("big.rs", &content.join("\n")); let arguments = serde_json::to_string(&ReadFileArgs { file_path: path.to_string_lossy().into(), ..Default::default() })?; @@ -68,7 +68,7 @@ async fn full_read_file_pages_reach_the_llm_whole() -> TestResult { #[tokio::test] async fn long_bash_output_keeps_head_and_tail_and_saves_the_full_output() -> TestResult { - let workspace = CodingWorkspace::new().await?; + let workspace = CodingWorkspace::new().await; let result = workspace.client.call("bash", BashInput { command: "seq 1 20000".into(), ..Default::default() }).await?; let output = result["output"].as_str().unwrap(); @@ -90,7 +90,7 @@ async fn long_web_pages_keep_head_and_tail_and_save_the_full_page() -> TestResul body: page.clone(), content_type: Some("text/plain".into()), }; - let workspace = CodingWorkspace::with_http(FakeHttpClient::new().with_response(url, response)).await?; + let workspace = CodingWorkspace::with_http(FakeHttpClient::new().with_response(url, response)).await; let result = workspace.client.call("web_fetch", WebFetchInput { url: url.into(), prompt: None, timeout: None }).await?; @@ -105,7 +105,7 @@ async fn long_web_pages_keep_head_and_tail_and_save_the_full_page() -> TestResul #[tokio::test] async fn short_output_is_returned_whole() -> TestResult { - let workspace = CodingWorkspace::new().await?; + let workspace = CodingWorkspace::new().await; let result = workspace.client.call("bash", BashInput { command: "seq 1 3".into(), ..Default::default() }).await?; assert_eq!(result["output"], "1\n2\n3\n"); Ok(()) @@ -113,8 +113,8 @@ async fn short_output_is_returned_whole() -> TestResult { #[tokio::test] async fn read_file_reports_invalid_and_missing_paths() -> TestResult { - let workspace = CodingWorkspace::new().await?; - let path = workspace.write("present.txt", "present")?; + let workspace = CodingWorkspace::new().await; + let path = workspace.write("present.txt", "present"); let invalid = workspace.client.call_raw("read_file", serde_json::json!({ "filePath": path, "offset": 0 })).await?; assert!(invalid.is_error.unwrap_or(false), "invalid offset should fail: {invalid:?}"); let missing = workspace @@ -127,7 +127,7 @@ async fn read_file_reports_invalid_and_missing_paths() -> TestResult { #[tokio::test] async fn write_file_handles_empty_content_and_overwrites() -> TestResult { - let workspace = CodingWorkspace::new().await?; + let workspace = CodingWorkspace::new().await; let path = workspace.path("nested/output.txt"); let result = workspace.client.call("write_file", serde_json::json!({ "file_path": path, "content": "" })).await?; assert_eq!(result["bytesWritten"], 0); @@ -147,8 +147,8 @@ async fn write_file_handles_empty_content_and_overwrites() -> TestResult { #[tokio::test] async fn edit_file_supports_batch_edits_and_response_metadata() -> TestResult { - let workspace = CodingWorkspace::new().await?; - let path = workspace.write("source.txt", "alpha\nbeta\ngamma\n")?; + let workspace = CodingWorkspace::new().await; + let path = workspace.write("source.txt", "alpha\nbeta\ngamma\n"); workspace .client .call("read_file", ReadFileArgs { file_path: path.to_string_lossy().into(), ..Default::default() }) @@ -176,7 +176,7 @@ async fn edit_file_supports_batch_edits_and_response_metadata() -> TestResult { #[tokio::test] async fn test_bash_pwd_uses_workspace_root() -> TestResult { - let workspace = CodingWorkspace::new().await?; + let workspace = CodingWorkspace::new().await; let parsed = workspace.client.call("bash", BashInput { command: "pwd".to_string(), ..Default::default() }).await?; let pwd = parsed["output"].as_str().ok_or_else(|| test_error("Expected output string"))?.trim(); assert_eq!(canonicalize(pwd)?, canonicalize(workspace.root())?); @@ -185,7 +185,7 @@ async fn test_bash_pwd_uses_workspace_root() -> TestResult { #[tokio::test] async fn coding_tool_catalog_uses_bash_for_search_and_directory_listings() -> TestResult { - let workspace = CodingWorkspace::new().await?; + let workspace = CodingWorkspace::new().await; let catalog = workspace.client.list_tools().await?; let names: Vec<_> = catalog.iter().map(|tool| tool.name.as_ref()).collect(); for retained in ["bash", "read_file", "write_file", "edit_file", "lsp_symbol"] { diff --git a/crates/mcp-servers/tests/integration/common/mod.rs b/crates/mcp-servers/tests/integration/common/mod.rs index 8e449587e..9ee9e1481 100644 --- a/crates/mcp-servers/tests/integration/common/mod.rs +++ b/crates/mcp-servers/tests/integration/common/mod.rs @@ -2,14 +2,13 @@ #![allow(dead_code)] -use aether_lspd::testing::TestProject; use mcp_servers::coding::CodingMcp; use mcp_servers::coding::tools::web_fetch::WebFetcher; use mcp_servers::skills::{ SkillsMcp, tools::{LoadSkillsInput, SkillRequest}, }; -use mcp_servers::testing::FakeHttpClient; +use mcp_servers::testing::{FakeHttpClient, TestWorkspace}; use mcp_utils::client::{ClientOptions, McpClient}; use mcp_utils::testing::{ElicitationScript, args, call, elicitation_host}; use rmcp::ServerHandler; @@ -19,10 +18,9 @@ use rmcp::model::{ }; use serde::Serialize; use serde_json::Value; -use std::fs::{create_dir_all, read_to_string}; +use std::fs::read_to_string; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -use tempfile::{TempDir, tempdir}; /// Default timeout for polling operations (60 seconds). const POLL_TIMEOUT: Duration = Duration::from_mins(1); @@ -53,7 +51,7 @@ pub fn taskless() -> ClientOptions { ClientOptions::default().without_tasks() } -pub fn coding_with_lsp(project: &impl TestProject) -> CodingMcp { +pub fn coding_with_lsp(project: &TestWorkspace) -> CodingMcp { CodingMcp::new(project.root()).with_lsp() } @@ -86,44 +84,39 @@ pub fn test_error(message: impl Into) -> std::io::Error { /// An isolated workspace connected to a `CodingMcp` through the public MCP protocol. pub struct CodingWorkspace { - root: TempDir, + workspace: TestWorkspace, pub client: McpClient, } impl CodingWorkspace { - pub async fn new() -> TestResult { + pub async fn new() -> Self { Self::start(|root| CodingMcp::new(root)).await } - pub async fn new_with_lsp() -> TestResult { + pub async fn new_with_lsp() -> Self { Self::start(|root| CodingMcp::new(root).with_lsp()).await } - pub async fn with_http(http: FakeHttpClient) -> TestResult { + pub async fn with_http(http: FakeHttpClient) -> Self { Self::start(|root| CodingMcp::new(root).with_web_fetcher(WebFetcher::with_client(http))).await } - async fn start(configure: impl FnOnce(&Path) -> CodingMcp) -> TestResult { - let root = tempdir()?; - let client = connect(configure(root.path()), ClientOptions::default()).await; - Ok(Self { root, client }) + async fn start(configure: impl FnOnce(&Path) -> CodingMcp) -> Self { + let workspace = TestWorkspace::new(); + let client = connect(configure(workspace.root()), ClientOptions::default()).await; + Self { workspace, client } } pub fn root(&self) -> &Path { - self.root.path() + self.workspace.root() } pub fn path(&self, relative_path: impl AsRef) -> PathBuf { - self.root.path().join(relative_path) + self.workspace.path(relative_path) } - pub fn write(&self, relative_path: impl AsRef, content: &str) -> TestResult { - let path = self.path(relative_path); - if let Some(parent) = path.parent() { - create_dir_all(parent)?; - } - std::fs::write(&path, content)?; - Ok(path) + pub fn write(&self, relative_path: impl AsRef, content: &str) -> PathBuf { + self.workspace.write(relative_path, content) } pub fn read(&self, relative_path: impl AsRef) -> TestResult { @@ -222,7 +215,7 @@ pub fn has_no_errors(result: &serde_json::Value) -> bool { error_count(result).is_some_and(|n| n == 0) } -pub async fn cleanup_daemon(project: &impl TestProject) { +pub async fn cleanup_daemon(project: &TestWorkspace) { use aether_lspd::{LanguageId, socket_path}; for lang in [LanguageId::Rust, LanguageId::TypeScript] { let sock = socket_path(project.root(), lang); @@ -232,20 +225,6 @@ pub async fn cleanup_daemon(project: &impl TestProject) { } } -/// Creates files and directories (including parents) from `(path, content)` -/// pairs inside a fresh temp dir. -pub fn create_test_files(files: &[(&str, &str)]) -> TempDir { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - for (path, content) in files { - let full_path = temp_dir.path().join(path); - if let Some(parent) = full_path.parent() { - create_dir_all(parent).unwrap_or_else(|_| panic!("Failed to create directory for {path}")); - } - std::fs::write(&full_path, content).unwrap_or_else(|_| panic!("Failed to write file {path}")); - } - temp_dir -} - /// A skills server serving the `skills` directory of `test_dir`. pub fn skills_server(test_dir: &Path) -> SkillsMcp { SkillsMcp::new(test_dir, &[test_dir.join("skills")]) diff --git a/crates/mcp-servers/tests/integration/lsp_check_errors_contract.rs b/crates/mcp-servers/tests/integration/lsp_check_errors_contract.rs index cfe87efc1..910a29225 100644 --- a/crates/mcp-servers/tests/integration/lsp_check_errors_contract.rs +++ b/crates/mcp-servers/tests/integration/lsp_check_errors_contract.rs @@ -1,19 +1,19 @@ use crate::common::{CodingWorkspace, call_tool, call_tool_error, coding_with_lsp, connect}; -use aether_lspd::testing::{CargoProject, TestProject}; +use aether_lspd::testing::cargo_project; use mcp_utils::client::ClientOptions; use std::os::unix::fs::PermissionsExt; #[tokio::test] async fn lsp_check_errors_accepts_flat_file_path_and_infers_file_scope() { - let project = CargoProject::new("diag_contract_infers_file_scope").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("diag_contract_infers_file_scope"); + project.write("src/main.rs", "fn main() {}\n"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let result = call_tool( &client, "lsp_check_errors", serde_json::json!({ - "filePath": project.file_path_str("src/main.rs") + "filePath": project.path_string("src/main.rs") }), ) .await; @@ -23,8 +23,8 @@ async fn lsp_check_errors_accepts_flat_file_path_and_infers_file_scope() { #[tokio::test] async fn lsp_check_errors_schema_is_flat_and_has_only_file_path() { - let project = CargoProject::new("diag_contract_flat_schema").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("diag_contract_flat_schema"); + project.write("src/main.rs", "fn main() {}\n"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let tools = client.list_tools().await.expect("list tools"); @@ -42,8 +42,8 @@ async fn lsp_check_errors_schema_is_flat_and_has_only_file_path() { #[tokio::test] async fn lsp_check_errors_rejects_redundant_scope_parameter() { - let project = CargoProject::new("diag_contract_redundant_scope").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("diag_contract_redundant_scope"); + project.write("src/main.rs", "fn main() {}\n"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let error = call_tool_error(&client, "lsp_check_errors", serde_json::json!({ "scope": "workspace" })).await; @@ -53,7 +53,7 @@ async fn lsp_check_errors_rejects_redundant_scope_parameter() { #[tokio::test] async fn lsp_check_errors_fails_when_workspace_has_no_active_language_server() { - let workspace = CodingWorkspace::new_with_lsp().await.expect("create workspace"); + let workspace = CodingWorkspace::new_with_lsp().await; let error = call_tool_error(&workspace.client, "lsp_check_errors", serde_json::json!({})).await; assert!(error.contains("No active LSP clients"), "{error}"); @@ -61,10 +61,10 @@ async fn lsp_check_errors_fails_when_workspace_has_no_active_language_server() { #[tokio::test] async fn lsp_check_errors_returns_typescript_installation_instructions_when_server_fails() { - let workspace = CodingWorkspace::new_with_lsp().await.expect("create workspace"); - workspace.write("package.json", "{}\n").expect("write package.json"); - let index_ts = workspace.write("index.ts", "const value: string = 1;\n").expect("write TypeScript file"); - workspace.write("node_modules/.bin/tsc", "not executable\n").expect("write unavailable language server"); + let workspace = CodingWorkspace::new_with_lsp().await; + workspace.write("package.json", "{}\n"); + let index_ts = workspace.write("index.ts", "const value: string = 1;\n"); + workspace.write("node_modules/.bin/tsc", "not executable\n"); let error = call_tool_error(&workspace.client, "lsp_check_errors", serde_json::json!({ "filePath": index_ts })).await; @@ -77,12 +77,11 @@ async fn lsp_check_errors_returns_typescript_installation_instructions_when_serv #[tokio::test] async fn lsp_check_errors_returns_typescript_installation_instructions_when_tsc_predates_lsp() { - let workspace = CodingWorkspace::new_with_lsp().await.expect("create workspace"); - workspace.write("package.json", "{}\n").expect("write package.json"); - let index_ts = workspace.write("index.ts", "const value: string = 1;\n").expect("write TypeScript file"); + let workspace = CodingWorkspace::new_with_lsp().await; + workspace.write("package.json", "{}\n"); + let index_ts = workspace.write("index.ts", "const value: string = 1;\n"); let tsc = workspace - .write("node_modules/.bin/tsc", "#!/bin/sh\necho \"error TS5023: Unknown compiler option '--lsp'.\"\nexit 1\n") - .expect("write TypeScript 6 tsc"); + .write("node_modules/.bin/tsc", "#!/bin/sh\necho \"error TS5023: Unknown compiler option '--lsp'.\"\nexit 1\n"); std::fs::set_permissions(&tsc, std::fs::Permissions::from_mode(0o755)).expect("make tsc executable"); let error = @@ -95,8 +94,8 @@ async fn lsp_check_errors_returns_typescript_installation_instructions_when_tsc_ #[tokio::test] async fn lsp_check_errors_rejects_file_scope_parameter() { - let project = CargoProject::new("diag_contract_rejects_scope").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("diag_contract_rejects_scope"); + project.write("src/main.rs", "fn main() {}\n"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let error = call_tool_error( @@ -104,7 +103,7 @@ async fn lsp_check_errors_rejects_file_scope_parameter() { "lsp_check_errors", serde_json::json!({ "scope": "file", - "filePath": project.file_path_str("src/main.rs") + "filePath": project.path_string("src/main.rs") }), ) .await; @@ -114,8 +113,8 @@ async fn lsp_check_errors_rejects_file_scope_parameter() { #[tokio::test] async fn lsp_check_errors_rejects_file_scope_directory_path() { - let project = CargoProject::new("diag_contract_file_rejects_directory").expect("Failed to create project"); - project.add_file("src/main.rs", "fn main() {}\n").expect("Failed to add file"); + let project = cargo_project("diag_contract_file_rejects_directory"); + project.write("src/main.rs", "fn main() {}\n"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let error = call_tool_error( diff --git a/crates/mcp-servers/tests/integration/lsp_diagnostics_e2e.rs b/crates/mcp-servers/tests/integration/lsp_diagnostics_e2e.rs index 0eb04ec33..39186f3b3 100644 --- a/crates/mcp-servers/tests/integration/lsp_diagnostics_e2e.rs +++ b/crates/mcp-servers/tests/integration/lsp_diagnostics_e2e.rs @@ -10,7 +10,7 @@ //! Run with: `cargo test -p mcp-servers -- lsp_diagnostics` use crate::common::{call_tool, coding_with_lsp, connect, has_errors, has_no_errors, poll_diagnostics, try_call_tool}; -use aether_lspd::testing::{CargoProject, TestProject}; +use aether_lspd::testing::cargo_project; use mcp_utils::client::{ClientOptions, McpClient}; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -60,19 +60,17 @@ fn canonical_path(path: &str) -> String { /// Test: MCP `edit_file` tool → rust-analyzer picks up change → diagnostics queryable #[tokio::test] async fn test_mcp_edit_produces_diagnostics() { - let project = CargoProject::new("mcp_edit_diag").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("mcp_edit_diag"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = "not an int"; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -116,19 +114,17 @@ async fn test_mcp_edit_produces_diagnostics() { /// the LSP to re-publish diagnostics after syncing a changed document. #[tokio::test] async fn test_diagnostics_available_after_edit_without_polling() { - let project = CargoProject::new("diag_after_edit_no_poll").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("diag_after_edit_no_poll"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = 42; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -154,19 +150,17 @@ async fn test_diagnostics_available_after_edit_without_polling() { /// documents before returning the cache. #[tokio::test] async fn test_diagnostics_all_files_after_edit() { - let project = CargoProject::new("diag_all_files").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("diag_all_files"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = 42; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -192,19 +186,17 @@ async fn test_diagnostics_all_files_after_edit() { /// diagnostics request has ever been made. #[tokio::test] async fn test_workspace_diagnostics_after_edit_without_file_check() { - let project = CargoProject::new("diag_workspace_after_edit").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("diag_workspace_after_edit"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = 42; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -230,19 +222,17 @@ async fn test_workspace_diagnostics_after_edit_without_file_check() { /// file-scoped diagnostics request. #[tokio::test] async fn test_workspace_diagnostics_clear_after_fix_without_file_check() { - let project = CargoProject::new("diag_workspace_fix").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("diag_workspace_fix"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = "not an int"; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -282,19 +272,17 @@ async fn test_workspace_diagnostics_clear_after_fix_without_file_check() { /// cache, not just previously-opened documents. #[tokio::test] async fn test_diagnostics_all_files_after_external_edit() { - let project = CargoProject::new("diag_all_ext_edit").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("diag_all_ext_edit"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = 42; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let main_rs_path = project.root().join("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -319,19 +307,17 @@ async fn test_diagnostics_all_files_after_external_edit() { /// diagnostics cache fresh, so the daemon should simply return whatever is cached. #[tokio::test] async fn test_diagnostics_all_files_after_external_edit_single_call() { - let project = CargoProject::new("diag_ext_single_call").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("diag_ext_single_call"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = 42; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let main_rs_path = project.root().join("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -354,19 +340,17 @@ async fn test_diagnostics_all_files_after_external_edit_single_call() { /// Test: External `fs::write` → file watcher → diagnostics queryable #[tokio::test] async fn test_external_file_change_produces_diagnostics() { - let project = CargoProject::new("ext_write_diag").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("ext_write_diag"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = "not an int"; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let main_rs_path = project.root().join("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -413,17 +397,15 @@ async fn test_external_file_change_produces_diagnostics() { /// for workspace scope, this file will be invisible. #[tokio::test] async fn test_diagnostics_all_files_discovers_file_watcher_uris() { - let project = CargoProject::new("diag_fw_discover").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("diag_fw_discover"); + project.write( + "src/main.rs", + r#"fn main() { let x: i32 = 42; println!("{}", x); } "#, - ) - .expect("Failed to add file"); + ); let main_rs_path = project.root().join("src/main.rs"); diff --git a/crates/mcp-servers/tests/integration/lsp_operations_e2e.rs b/crates/mcp-servers/tests/integration/lsp_operations_e2e.rs index 1fa721c6b..ddf15eb74 100644 --- a/crates/mcp-servers/tests/integration/lsp_operations_e2e.rs +++ b/crates/mcp-servers/tests/integration/lsp_operations_e2e.rs @@ -8,25 +8,23 @@ //! Run with: `cargo test -p mcp-servers -- lsp_operations` use crate::common::{call_tool, coding_with_lsp, connect, poll_lsp_tool}; -use aether_lspd::testing::{CargoProject, TestProject}; +use aether_lspd::testing::cargo_project; use mcp_utils::client::ClientOptions; /// Test: hover returns type information for a Rust variable #[tokio::test] async fn test_hover_falls_back_from_stale_line_hint() { - let project = CargoProject::new("hover_test").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn main() { + let project = cargo_project("hover_test"); + project.write( + "src/main.rs", + r#"fn main() { let x: Vec = vec![1, 2, 3]; println!("{:?}", x); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let result = poll_lsp_tool( @@ -49,11 +47,10 @@ async fn test_hover_falls_back_from_stale_line_hint() { /// Test: goto definition resolves to the correct function definition #[tokio::test] async fn test_goto_definition() { - let project = CargoProject::new("definition_test").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn greet() -> &'static str { + let project = cargo_project("definition_test"); + project.write( + "src/main.rs", + r#"fn greet() -> &'static str { "hello" } @@ -62,10 +59,9 @@ fn main() { println!("{}", msg); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let result = poll_lsp_tool( @@ -87,11 +83,10 @@ fn main() { /// Test: find references returns all usages of a symbol #[tokio::test] async fn test_find_references() { - let project = CargoProject::new("references_test").expect("Failed to create project"); - project - .add_file( - "src/main.rs", - r#"fn greet() -> &'static str { + let project = cargo_project("references_test"); + project.write( + "src/main.rs", + r#"fn greet() -> &'static str { "hello" } @@ -101,10 +96,9 @@ fn main() { println!("{} {}", a, b); } "#, - ) - .expect("Failed to add file"); + ); - let main_rs = project.file_path_str("src/main.rs"); + let main_rs = project.path_string("src/main.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let result = poll_lsp_tool( @@ -130,29 +124,25 @@ fn main() { /// Test: `lsp_rename` applies workspace edits for a Rust symbol #[tokio::test] async fn test_lsp_rename_falls_back_from_stale_line_hint() { - let project = CargoProject::new("rename_test").expect("Failed to create project"); - project - .add_file( - "src/lib.rs", - r#"pub fn greet() -> &'static str { + let project = cargo_project("rename_test"); + project.write( + "src/lib.rs", + r#"pub fn greet() -> &'static str { "hello" } "#, - ) - .expect("Failed to add lib.rs"); - project - .add_file( - "src/main.rs", - r#"fn main() { + ); + project.write( + "src/main.rs", + r#"fn main() { let a = rename_test::greet(); let b = rename_test::greet(); println!("{} {}", a, b); } "#, - ) - .expect("Failed to add main.rs"); + ); - let lib_rs = project.file_path_str("src/lib.rs"); + let lib_rs = project.path_string("src/lib.rs"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; // Wait for rust-analyzer to fully index both files before calling rename. diff --git a/crates/mcp-servers/tests/integration/lsp_ts_diagnostics_e2e.rs b/crates/mcp-servers/tests/integration/lsp_ts_diagnostics_e2e.rs index 7bac76f45..1ec664e05 100644 --- a/crates/mcp-servers/tests/integration/lsp_ts_diagnostics_e2e.rs +++ b/crates/mcp-servers/tests/integration/lsp_ts_diagnostics_e2e.rs @@ -1,15 +1,13 @@ use crate::common::{call_tool, coding_with_lsp, connect, has_errors, has_no_errors}; -use aether_lspd::testing::{NodeProject, TestProject}; +use aether_lspd::testing::node_project; use mcp_utils::client::{ClientOptions, McpClient}; #[tokio::test] async fn test_ts_mcp_edit_produces_diagnostics() { - let project = NodeProject::new("ts_mcp_edit_diag").expect("Failed to create project"); - project - .add_file("src/index.ts", "const x: number = \"not a number\";\nconsole.log(x);\n") - .expect("Failed to add file"); + let project = node_project("ts_mcp_edit_diag"); + project.write("src/index.ts", "const x: number = \"not a number\";\nconsole.log(x);\n"); - let index_ts = project.file_path_str("src/index.ts"); + let index_ts = project.path_string("src/index.ts"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -49,12 +47,10 @@ async fn test_ts_mcp_edit_produces_diagnostics() { #[tokio::test] async fn test_ts_external_file_change_produces_diagnostics() { - let project = NodeProject::new("ts_ext_write_diag").expect("Failed to create project"); - project - .add_file("src/index.ts", "const x: number = \"not a number\";\nconsole.log(x);\n") - .expect("Failed to add file"); + let project = node_project("ts_ext_write_diag"); + project.write("src/index.ts", "const x: number = \"not a number\";\nconsole.log(x);\n"); - let index_ts = project.file_path_str("src/index.ts"); + let index_ts = project.path_string("src/index.ts"); let index_ts_path = project.root().join("src/index.ts"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -75,10 +71,10 @@ async fn test_ts_external_file_change_produces_diagnostics() { #[tokio::test] async fn test_ts_diagnostics_after_edit_without_polling() { - let project = NodeProject::new("ts_diag_no_poll").expect("Failed to create project"); - project.add_file("src/index.ts", "const x: number = 42;\nconsole.log(x);\n").expect("Failed to add file"); + let project = node_project("ts_diag_no_poll"); + project.write("src/index.ts", "const x: number = 42;\nconsole.log(x);\n"); - let index_ts = project.file_path_str("src/index.ts"); + let index_ts = project.path_string("src/index.ts"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; diff --git a/crates/mcp-servers/tests/integration/lsp_ts_operations_e2e.rs b/crates/mcp-servers/tests/integration/lsp_ts_operations_e2e.rs index aa509db1c..9395c2a5d 100644 --- a/crates/mcp-servers/tests/integration/lsp_ts_operations_e2e.rs +++ b/crates/mcp-servers/tests/integration/lsp_ts_operations_e2e.rs @@ -1,13 +1,13 @@ use crate::common::{coding_with_lsp, connect, poll_lsp_tool}; -use aether_lspd::testing::{NodeProject, TestProject}; +use aether_lspd::testing::node_project; use mcp_utils::client::ClientOptions; #[tokio::test] async fn test_ts_hover_returns_type_info() { - let project = NodeProject::new("ts_hover_test").expect("Failed to create project"); - project.add_file("src/index.ts", "const x: number = 42;\nconsole.log(x);\n").expect("Failed to add file"); + let project = node_project("ts_hover_test"); + project.write("src/index.ts", "const x: number = 42;\nconsole.log(x);\n"); - let index_ts = project.file_path_str("src/index.ts"); + let index_ts = project.path_string("src/index.ts"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let result = poll_lsp_tool( @@ -29,19 +29,17 @@ async fn test_ts_hover_returns_type_info() { #[tokio::test] async fn test_ts_goto_definition() { - let project = NodeProject::new("ts_def_test").expect("Failed to create project"); - project - .add_file( - "src/index.ts", - r#"function greet(): string { + let project = node_project("ts_def_test"); + project.write( + "src/index.ts", + r#"function greet(): string { return "hello"; } const msg = greet(); console.log(msg); "#, - ) - .expect("Failed to add file"); + ); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; @@ -63,11 +61,10 @@ console.log(msg); #[tokio::test] async fn test_ts_find_references() { - let project = NodeProject::new("ts_refs_test").expect("Failed to create project"); - project - .add_file( - "src/index.ts", - r#"function greet(): string { + let project = node_project("ts_refs_test"); + project.write( + "src/index.ts", + r#"function greet(): string { return "hello"; } @@ -75,10 +72,9 @@ const a = greet(); const b = greet(); console.log(a, b); "#, - ) - .expect("Failed to add file"); + ); - let index_ts = project.file_path_str("src/index.ts"); + let index_ts = project.path_string("src/index.ts"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; let result = poll_lsp_tool( diff --git a/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs b/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs index 21767c470..d0a585bd5 100644 --- a/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs +++ b/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs @@ -1,6 +1,5 @@ use crate::common::{ - TestResult, await_terminal_task, call_task, cancels_prompts, connect, create_test_files, taskless, test_error, - tool_request, + TestResult, await_terminal_task, call_task, cancels_prompts, connect, taskless, test_error, tool_request, }; use aether_auth::FakeOAuthCredentialStore; use aether_core::core::AgentDeps; @@ -12,12 +11,12 @@ use futures::StreamExt; use mcp_servers::agent_mcp; use mcp_servers::subagents::SubAgentsMcp; use mcp_servers::subagents::tools::{SpawnSubAgentsInput, SubAgentTask}; +use mcp_servers::testing::TestWorkspace; use mcp_utils::client::{ClientOptions, McpClient, ToolCallEvent, ToolCallOptions}; use mcp_utils::testing::args; use rmcp::model::{CallToolResponse, CallToolResult, CancelTaskParams, GetTaskParams, TaskPayload}; use std::path::Path; use std::sync::{Arc, Mutex}; -use tempfile::TempDir; fn spawn_input(tasks: &[(&str, &str)]) -> SpawnSubAgentsInput { SpawnSubAgentsInput { @@ -40,9 +39,9 @@ const RUNTIME_PROVIDER_URL: &str = "http://127.0.0.1:1"; #[tokio::test] async fn spawn_uses_runtime_registry_and_provider_overrides() -> TestResult { - let temp_dir = create_project_with_invocable_agent(); + let workspace = create_project_with_invocable_agent(); let settings = AetherSettings::load( - temp_dir.path(), + workspace.root(), [AetherSettingsSource::Json( r#"{ "agents": [{ @@ -60,10 +59,10 @@ async fn spawn_uses_runtime_registry_and_provider_overrides() -> TestResult { provider.merge(llm::ProviderConnectionOverride::auth(llm::ProviderAuthMode::None)); let overrides = llm::ProviderConnectionOverrides::new(std::collections::BTreeMap::from([("anthropic".to_string(), provider)])); - let catalog = AgentCatalog::from_settings(temp_dir.path(), settings)?.with_provider_connections(overrides); + let catalog = AgentCatalog::from_settings(workspace.root(), settings)?.with_provider_connections(overrides); let deps = AgentDeps::default().with_agent_registry(catalog.registry().clone()); let result = call_subagent_through_manager( - temp_dir.path(), + workspace.root(), deps, spawn_input(&[("runtime-explorer", "Do something"), ("coder", "Do something")]), ) @@ -76,9 +75,9 @@ async fn spawn_uses_runtime_registry_and_provider_overrides() -> TestResult { #[tokio::test] async fn embedded_subagents_use_runtime_catalog_instead_of_checkout_settings() { - let temp_dir = create_project_with_invocable_agent(); + let workspace = create_project_with_invocable_agent(); let runtime_settings = AetherSettings::load( - temp_dir.path(), + workspace.root(), [AetherSettingsSource::Json( r#"{ "agents": [ @@ -96,9 +95,9 @@ async fn embedded_subagents_use_runtime_catalog_instead_of_checkout_settings() { ) .expect("Failed to load runtime settings"); let runtime_catalog = - AgentCatalog::from_settings(temp_dir.path(), runtime_settings).expect("Failed to create runtime catalog"); + AgentCatalog::from_settings(workspace.root(), runtime_settings).expect("Failed to create runtime catalog"); - let instructions = subagent_instructions(temp_dir.path(), runtime_catalog).await; + let instructions = subagent_instructions(workspace.root(), runtime_catalog).await; assert!(instructions.contains("runtime-explorer"), "runtime catalog agent missing: {instructions}"); assert!(!instructions.contains("coder"), "workspace settings leaked into instructions: {instructions}"); @@ -106,8 +105,9 @@ async fn embedded_subagents_use_runtime_catalog_instead_of_checkout_settings() { #[tokio::test] async fn embedded_subagents_report_no_agents_when_runtime_catalog_is_empty() { - let temp_dir = create_project_with_invocable_agent(); - let instructions = subagent_instructions(temp_dir.path(), AgentCatalog::empty(temp_dir.path().to_path_buf())).await; + let workspace = create_project_with_invocable_agent(); + let instructions = + subagent_instructions(workspace.root(), AgentCatalog::empty(workspace.root().to_path_buf())).await; assert!(instructions.contains("No sub-agents are currently available"), "unexpected instructions: {instructions}"); assert!(!instructions.contains("coder"), "workspace settings leaked into instructions: {instructions}"); } @@ -134,16 +134,16 @@ async fn test_spawn_agent_with_coding_mcp_from_settings_catalog() { (".aether/mcp/coder.json", r#"{"servers": {"coding": {"type": "in-memory"}}}"#), ]; - let temp_dir = create_test_files(&test_files); - let _mcp = connect(create_test_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let _mcp = connect(create_test_server(workspace.root()), ClientOptions::default()).await; } #[tokio::test] async fn test_spawn_subagent_codex_uses_oauth_store() -> TestResult { - let temp_dir = create_project_with_codex_agent(); + let workspace = create_project_with_codex_agent(); let deps = AgentDeps::new(Arc::new(FakeOAuthCredentialStore::new()), None) - .with_agent_registry(test_registry(temp_dir.path())); - let mcp = connect(SubAgentsMcp::new(temp_dir.path().to_path_buf(), deps), cancels_prompts()).await; + .with_agent_registry(test_registry(workspace.root())); + let mcp = connect(SubAgentsMcp::new(workspace.root().to_path_buf(), deps), cancels_prompts()).await; let parsed = call_complete(&mcp, spawn_input(&[("explorer", "Read README.md")])).await?; @@ -154,8 +154,8 @@ async fn test_spawn_subagent_codex_uses_oauth_store() -> TestResult { #[tokio::test] async fn spawn_subagent_empty_batch_completes_with_empty_output() -> TestResult { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let mcp = connect(create_test_server(temp_dir.path()), taskless()).await; + let workspace = TestWorkspace::new(); + let mcp = connect(create_test_server(workspace.root()), taskless()).await; let parsed = call_complete(&mcp, spawn_input(&[])).await?; @@ -168,8 +168,8 @@ async fn spawn_subagent_empty_batch_completes_with_empty_output() -> TestResult #[tokio::test] async fn test_spawn_subagent_errors_when_no_invocable_agents() -> TestResult { - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let mcp = connect(create_test_server(temp_dir.path()), cancels_prompts()).await; + let workspace = TestWorkspace::new(); + let mcp = connect(create_test_server(workspace.root()), cancels_prompts()).await; let error = mcp .peer() @@ -186,8 +186,8 @@ async fn test_spawn_subagent_errors_when_no_invocable_agents() -> TestResult { #[tokio::test] async fn spawn_subagent_defaults_to_foreground_without_tasks_capability() -> TestResult { - let temp_dir = create_project_with_invocable_agent(); - let mcp = connect(create_test_server(temp_dir.path()), taskless()).await; + let workspace = create_project_with_invocable_agent(); + let mcp = connect(create_test_server(workspace.root()), taskless()).await; let parsed = call_complete(&mcp, spawn_input(&[("nonexistent-agent", "Do something")])).await?; @@ -206,8 +206,8 @@ async fn spawn_subagent_defaults_to_foreground_without_tasks_capability() -> Tes #[tokio::test] async fn spawn_subagent_preserves_child_result_order_and_ids() -> TestResult { - let temp_dir = create_project_with_invocable_agent(); - let mcp = task_client(temp_dir.path()).await; + let workspace = create_project_with_invocable_agent(); + let mcp = task_client(workspace.root()).await; let parsed = call_complete(&mcp, spawn_input(&[("missing-agent-a", "First task"), ("missing-agent-b", "Second task")])) @@ -223,8 +223,8 @@ async fn spawn_subagent_preserves_child_result_order_and_ids() -> TestResult { #[tokio::test] async fn spawn_subagent_background_returns_mcp_task() -> TestResult { - let temp_dir = create_project_with_invocable_agent(); - let mcp = task_client(temp_dir.path()).await; + let workspace = create_project_with_invocable_agent(); + let mcp = task_client(workspace.root()).await; let input = background_spawn_input(&[("missing-agent", "Do something")]); let result = call_task(&mcp, "spawn_subagent", serde_json::to_value(input)?).await?; @@ -236,8 +236,8 @@ async fn spawn_subagent_background_returns_mcp_task() -> TestResult { #[tokio::test] async fn cancelling_background_subagents_cancels_the_batch_without_publishing_a_result() -> TestResult { - let temp_dir = create_project_with_blocked_agent(); - let mcp = task_client(temp_dir.path()).await; + let workspace = create_project_with_blocked_agent(); + let mcp = task_client(workspace.root()).await; let response = mcp .peer() .call_tool_once(tool_request("spawn_subagent", background_spawn_input(&[("blocked", "Wait for MCP startup")]))) @@ -261,8 +261,8 @@ async fn cancelling_background_subagents_cancels_the_batch_without_publishing_a_ #[tokio::test] async fn background_instrumentation_finishes_when_the_batch_settles() -> TestResult { - let temp_dir = create_project_with_blocked_agent(); - let project_root = temp_dir.path().to_path_buf(); + let workspace = create_project_with_blocked_agent(); + let project_root = workspace.root().to_path_buf(); let finishes = Arc::new(Mutex::new(Vec::new())); let recorded = Arc::clone(&finishes); let mut deps = AgentDeps::default().with_agent_registry(test_registry(&project_root)); @@ -287,8 +287,8 @@ async fn background_instrumentation_finishes_when_the_batch_settles() -> TestRes #[tokio::test] async fn spawn_subagent_background_requires_tasks_capability() -> TestResult { - let temp_dir = create_project_with_invocable_agent(); - let mcp = connect(create_test_server(temp_dir.path()), taskless()).await; + let workspace = create_project_with_invocable_agent(); + let mcp = connect(create_test_server(workspace.root()), taskless()).await; let error = mcp .peer() @@ -347,8 +347,8 @@ fn result_text(result: &CallToolResult) -> String { } } -fn create_project_with_invocable_agent() -> TempDir { - create_test_files(&[ +fn create_project_with_invocable_agent() -> TestWorkspace { + TestWorkspace::new().files([ ( ".aether/settings.json", r#"{ @@ -367,8 +367,8 @@ fn create_project_with_invocable_agent() -> TempDir { ]) } -fn create_project_with_blocked_agent() -> TempDir { - create_test_files(&[( +fn create_project_with_blocked_agent() -> TestWorkspace { + TestWorkspace::new().files([( ".aether/settings.json", r#"{ "agents": [{ @@ -383,8 +383,8 @@ fn create_project_with_blocked_agent() -> TempDir { )]) } -fn create_project_with_codex_agent() -> TempDir { - create_test_files(&[( +fn create_project_with_codex_agent() -> TestWorkspace { + TestWorkspace::new().files([( ".aether/settings.json", r#"{ "agents": [ diff --git a/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs b/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs index 3d6fe635a..7da1e3e00 100644 --- a/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs +++ b/crates/mcp-servers/tests/integration/plugins_mcp_skills.rs @@ -1,5 +1,6 @@ -use crate::common::{McpClientExt, TestResult, connect, create_test_files, load_skills_input, skills_server}; +use crate::common::{McpClientExt, TestResult, connect, load_skills_input, skills_server}; use mcp_servers::skills::tools::ListSkillsInput; +use mcp_servers::testing::TestWorkspace; use mcp_utils::client::ClientOptions; #[tokio::test] @@ -19,8 +20,8 @@ async fn test_load_skills_tool() -> TestResult { ), ]; - let temp_dir = create_test_files(&test_files); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let parsed = mcp.call("get_skills", load_skills_input(&[("skill-1", None), ("skill-2", None), ("skill-3", None)])).await?; @@ -58,8 +59,8 @@ async fn test_list_skills_only_returns_agent_invocable_entries() -> TestResult { ), ]; - let temp_dir = create_test_files(&test_files); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let parsed = mcp.call("list_skills", ListSkillsInput::default()).await?; @@ -86,8 +87,8 @@ async fn test_load_skills_with_missing() -> TestResult { ), ]; - let temp_dir = create_test_files(&test_files); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let parsed = mcp .call("get_skills", load_skills_input(&[("skill-1", None), ("nonexistent-skill", None), ("skill-2", None)])) @@ -124,8 +125,8 @@ async fn test_get_skills_rejects_non_agent_invocable_prompts() -> TestResult { ), ]; - let temp_dir = create_test_files(&test_files); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let parsed = mcp .call("get_skills", load_skills_input(&[("allowed", None), ("user-only", None), ("rule-only", None)])) @@ -158,8 +159,8 @@ async fn test_load_auxiliary_file() -> TestResult { ("skills/test-skill/references/REF.md", "# Reference\n\nReference content."), ]; - let temp_dir = create_test_files(&test_files); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let parsed = mcp.call("get_skills", load_skills_input(&[("test-skill", None)])).await?; let file = &parsed["files"][0]; @@ -181,8 +182,8 @@ async fn test_load_auxiliary_file() -> TestResult { async fn test_reject_traversal() -> TestResult { let test_files = vec![("skills/test-skill/SKILL.md", "---\ndescription: Test\nagent-invocable: true\n---\n# Test")]; - let temp_dir = create_test_files(&test_files); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let parsed = mcp.call("get_skills", load_skills_input(&[("test-skill", Some("../other-skill/SKILL.md"))])).await?; let file = &parsed["files"][0]; @@ -195,8 +196,8 @@ async fn test_reject_traversal() -> TestResult { async fn test_reject_absolute_path() -> TestResult { let test_files = vec![("skills/test-skill/SKILL.md", "---\ndescription: Test\nagent-invocable: true\n---\n# Test")]; - let temp_dir = create_test_files(&test_files); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new().files(test_files); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let parsed = mcp.call("get_skills", load_skills_input(&[("test-skill", Some("/etc/passwd"))])).await?; let file = &parsed["files"][0]; @@ -207,8 +208,8 @@ async fn test_reject_absolute_path() -> TestResult { #[tokio::test] async fn list_skills_input_schema_has_properties_object() -> TestResult { - let temp_dir = create_test_files(&[]); - let mcp = connect(skills_server(temp_dir.path()), ClientOptions::default()).await; + let workspace = TestWorkspace::new(); + let mcp = connect(skills_server(workspace.root()), ClientOptions::default()).await; let tools = mcp.list_tools().await?; let tool = tools.into_iter().find(|tool| tool.name.as_ref() == "list_skills").expect("list_skills tool present"); diff --git a/crates/mcp-servers/tests/lsp_symbol_errors_e2e.rs b/crates/mcp-servers/tests/lsp_symbol_errors_e2e.rs index da18ac1b3..b75967caf 100644 --- a/crates/mcp-servers/tests/lsp_symbol_errors_e2e.rs +++ b/crates/mcp-servers/tests/lsp_symbol_errors_e2e.rs @@ -2,7 +2,7 @@ mod common; use aether_lspd::LanguageId; -use aether_lspd::testing::{CargoProject, TestDaemon, TestProject, configure_fake_server}; +use aether_lspd::testing::{TestDaemon, cargo_project, configure_fake_server}; use common::{call_tool_error, coding_with_lsp, connect}; use mcp_utils::client::ClientOptions; use std::time::Duration; @@ -10,8 +10,8 @@ use std::time::Duration; #[tokio::test] async fn symbol_lookup_propagates_language_server_errors() { unsafe { configure_fake_server(LanguageId::Rust, &["--fail-on", "textDocument/definition"]) }; - let project = CargoProject::new("symbol_lookup_errors").expect("create project"); - project.add_file("src/lib.rs", "pub fn example_fn() {}\n").expect("add source file"); + let project = cargo_project("symbol_lookup_errors"); + project.write("src/lib.rs", "pub fn example_fn() {}\n"); let daemon = TestDaemon::spawn(project.root(), LanguageId::Rust, Duration::from_secs(120)).await.expect("start daemon"); let client = connect(coding_with_lsp(&project), ClientOptions::default()).await; diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 40a3d8e4c..45234a5f1 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -23,6 +23,9 @@ rustdoc-args = ["--cfg", "docsrs"] [lints] workspace = true +[features] +testing = [] + [dependencies] futures = { workspace = true } regex = { workspace = true } diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 68d572f52..9f7907f01 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -15,6 +15,8 @@ pub mod shell_expander; pub mod substitution; #[cfg(not(target_family = "wasm"))] pub mod temp_dir; +#[cfg(all(feature = "testing", not(target_family = "wasm")))] +pub mod testing; #[cfg(not(target_family = "wasm"))] pub mod tool_result_truncator; pub mod variables; diff --git a/crates/mcp-servers/src/testing/workspace.rs b/crates/utils/src/testing.rs similarity index 51% rename from crates/mcp-servers/src/testing/workspace.rs rename to crates/utils/src/testing.rs index 020b9c7f5..2ce67e007 100644 --- a/crates/mcp-servers/src/testing/workspace.rs +++ b/crates/utils/src/testing.rs @@ -1,14 +1,9 @@ -//! TempDir-backed workspace fixture for exercising file-based tools. - use std::fs; +#[cfg(unix)] use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use tempfile::TempDir; -/// A temporary workspace directory on the real filesystem. -/// -/// Builder methods create files and directories eagerly so each test reads as -/// a description of the workspace followed by assertions against it. pub struct TestWorkspace { root: TempDir, } @@ -24,47 +19,48 @@ impl TestWorkspace { Self { root: TempDir::new().expect("failed to create temp workspace") } } - /// Writes `contents` to `path` inside the workspace, creating parent directories. pub fn file(self, path: impl AsRef, contents: impl AsRef<[u8]>) -> Self { - let path = self.join(path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("failed to create parent directory"); - } - fs::write(path, contents).expect("failed to write file"); + self.write(path, contents); self } - /// Creates a directory (and any missing parents) inside the workspace. + pub fn files, C: AsRef<[u8]>>(self, files: impl IntoIterator) -> Self { + files.into_iter().fold(self, |workspace, (path, contents)| workspace.file(path, contents)) + } + pub fn dir(self, path: impl AsRef) -> Self { - fs::create_dir_all(self.join(path)).expect("failed to create directory"); + fs::create_dir_all(self.path(path)).expect("failed to create directory"); self } - /// Creates a symlink at `link` pointing to `target`; both resolved inside the workspace. #[cfg(unix)] pub fn symlink(self, target: impl AsRef, link: impl AsRef) -> Self { - symlink(self.join(target), self.join(link)).expect("failed to create symlink"); + symlink(self.path(target), self.path(link)).expect("failed to create symlink"); self } + pub fn write(&self, path: impl AsRef, contents: impl AsRef<[u8]>) -> PathBuf { + let path = self.path(path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("failed to create parent directory"); + } + fs::write(&path, contents).expect("failed to write file"); + path + } + pub fn root(&self) -> &Path { self.root.path() } pub fn root_string(&self) -> String { - self.root.path().to_string_lossy().into_owned() + self.root().to_string_lossy().into_owned() } - /// Resolves `path` inside the workspace without requiring it to exist. pub fn path(&self, path: impl AsRef) -> PathBuf { - self.join(path) + self.root().join(path) } pub fn path_string(&self, path: impl AsRef) -> String { - self.join(path).to_string_lossy().into_owned() - } - - fn join(&self, path: impl AsRef) -> PathBuf { - self.root.path().join(path) + self.path(path).to_string_lossy().into_owned() } } diff --git a/crates/wisp/Cargo.toml b/crates/wisp/Cargo.toml index f410cf74d..3dfaa91d6 100644 --- a/crates/wisp/Cargo.toml +++ b/crates/wisp/Cargo.toml @@ -75,3 +75,4 @@ acp_utils = { package = "aether-acp-utils", path = "../acp-utils", features = [" clankerdiff-git = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } +utils = { package = "aether-utils", path = "../utils", features = ["testing"], version = "0.2.19" } diff --git a/crates/wisp/src/surfaces/dropped_files.rs b/crates/wisp/src/surfaces/dropped_files.rs index 145c092c0..d295aa2dc 100644 --- a/crates/wisp/src/surfaces/dropped_files.rs +++ b/crates/wisp/src/surfaces/dropped_files.rs @@ -78,22 +78,15 @@ fn looks_like_absolute_path(s: &str) -> bool { mod tests { use super::*; use std::fs; - use tempfile::TempDir; + use utils::testing::TestWorkspace; - fn setup_files(dir: &TempDir, names: &[&str]) -> Vec { - names - .iter() - .map(|name| { - let p = dir.path().join(name); - fs::write(&p, b"test").unwrap(); - p - }) - .collect() + fn setup_files(workspace: &TestWorkspace, names: &[&str]) -> Vec { + names.iter().map(|name| workspace.write(name, b"test")).collect() } #[test] fn file_uri_for_png_parses_to_local_path() { - let tmp = TempDir::new().unwrap(); + let tmp = TestWorkspace::new(); let paths = setup_files(&tmp, &["image.png"]); let uri = format!("file://{}", paths[0].display()); @@ -103,7 +96,7 @@ mod tests { #[test] fn absolute_posix_path_parses_correctly() { - let tmp = TempDir::new().unwrap(); + let tmp = TestWorkspace::new(); let paths = setup_files(&tmp, &["photo.jpg"]); let input = paths[0].to_str().unwrap(); @@ -113,8 +106,8 @@ mod tests { #[test] fn quoted_path_with_spaces_parses_correctly() { - let tmp = TempDir::new().unwrap(); - let p = tmp.path().join("My File.png"); + let tmp = TestWorkspace::new(); + let p = tmp.path("My File.png"); fs::write(&p, b"test").unwrap(); let single_quoted = format!("'{}'", p.display()); @@ -128,8 +121,8 @@ mod tests { #[test] fn shell_escaped_path_with_spaces_parses_correctly() { - let tmp = TempDir::new().unwrap(); - let p = tmp.path().join("My File.png"); + let tmp = TestWorkspace::new(); + let p = tmp.path("My File.png"); fs::write(&p, b"test").unwrap(); let escaped = p.to_str().unwrap().replace(' ', "\\ "); @@ -139,7 +132,7 @@ mod tests { #[test] fn multiple_dropped_files_parse_correctly() { - let tmp = TempDir::new().unwrap(); + let tmp = TestWorkspace::new(); let paths = setup_files(&tmp, &["a.png", "b.wav"]); let input = format!("{}\n{}", paths[0].display(), paths[1].display()); @@ -149,7 +142,7 @@ mod tests { #[test] fn multiple_file_uris_parse_correctly() { - let tmp = TempDir::new().unwrap(); + let tmp = TestWorkspace::new(); let paths = setup_files(&tmp, &["a.png", "b.wav"]); let input = format!("file://{}\nfile://{}", paths[0].display(), paths[1].display()); @@ -174,9 +167,9 @@ mod tests { #[test] fn directory_shaped_absolute_path_is_parsed_lexically() { - let tmp = TempDir::new().unwrap(); - let input = tmp.path().to_str().unwrap(); - assert_eq!(parse_dropped_file_paths(input), Some(vec![tmp.path().to_path_buf()])); + let tmp = TestWorkspace::new(); + let input = tmp.root().to_str().unwrap(); + assert_eq!(parse_dropped_file_paths(input), Some(vec![tmp.root().to_path_buf()])); } #[test] @@ -187,7 +180,7 @@ mod tests { #[test] fn mixed_valid_and_invalid_returns_none() { - let tmp = TempDir::new().unwrap(); + let tmp = TestWorkspace::new(); let paths = setup_files(&tmp, &["a.png"]); let input = format!("{}\nhello world", paths[0].display()); assert!(parse_dropped_file_paths(&input).is_none()); diff --git a/crates/wisp/tests/tui/attachments.rs b/crates/wisp/tests/tui/attachments.rs index b31b94530..ad6183a63 100644 --- a/crates/wisp/tests/tui/attachments.rs +++ b/crates/wisp/tests/tui/attachments.rs @@ -36,12 +36,6 @@ fn model_select_option( .meta(SelectOptionMeta { reasoning_levels: vec![], supports_image, supports_audio }.into_meta()) } -fn create_temp_file(dir: &TempDir, name: &str, content: &[u8]) -> std::path::PathBuf { - let p = dir.path().join(name); - std::fs::write(&p, content).unwrap(); - p -} - fn image_model_config(current: &str, options: Vec) -> acp::SessionConfigOption { acp::SessionConfigOption::select( ConfigOptionId::Model.as_str().to_string(), @@ -76,8 +70,8 @@ fn make_select_group( #[test] fn remote_paste_never_attaches_even_when_the_path_exists_locally() { - let tmp = TempDir::new().unwrap(); - let image = create_temp_file(&tmp, "photo.png", b"client-only contents"); + let tmp = TestWorkspace::new(); + let image = tmp.write("photo.png", b"client-only contents"); let mut ui = TestUiBuilder::new().remote_workspace().build(); ui.paste(image.to_str().unwrap()); assert_eq!(ui.app().composer().pending_media().len(), 0); @@ -92,8 +86,8 @@ fn remote_paste_never_attaches_even_when_the_path_exists_locally() { #[test] fn paste_image_path_adds_pending_media() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png"); app.paste(img.to_str().unwrap()); @@ -105,8 +99,8 @@ fn paste_image_path_adds_pending_media() { #[test] fn paste_audio_path_adds_pending_media() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let audio = create_temp_file(&tmp, "note.wav", b"fake wav"); + let tmp = TestWorkspace::new(); + let audio = tmp.write("note.wav", b"fake wav"); app.paste(audio.to_str().unwrap()); @@ -127,8 +121,8 @@ fn paste_ordinary_text_inserts_as_text() { #[test] fn paste_non_media_file_falls_back_to_text() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let txt = create_temp_file(&tmp, "readme.txt", b"hello"); + let tmp = TestWorkspace::new(); + let txt = tmp.write("readme.txt", b"hello"); app.paste(txt.to_str().unwrap()); @@ -139,8 +133,8 @@ fn paste_non_media_file_falls_back_to_text() { #[test] fn paste_nonexistent_path_remains_as_text() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let missing = tmp.path().join("photo.png"); + let tmp = TestWorkspace::new(); + let missing = tmp.path("photo.png"); let input = missing.to_str().unwrap(); app.paste(input); @@ -152,8 +146,8 @@ fn paste_nonexistent_path_remains_as_text() { #[test] fn paste_nonexistent_file_uri_remains_as_text() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let missing = tmp.path().join("note.wav"); + let tmp = TestWorkspace::new(); + let missing = tmp.path("note.wav"); let uri = format!("file://{}", missing.display()); app.paste(&uri); @@ -165,9 +159,9 @@ fn paste_nonexistent_file_uri_remains_as_text() { #[test] fn paste_directory_path_remains_as_text() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); + let tmp = TestWorkspace::new(); // A directory with a media-like extension is the dangerous case. - let dir = tmp.path().join("assets.png"); + let dir = tmp.path("assets.png"); std::fs::create_dir(&dir).unwrap(); let input = dir.to_str().unwrap(); @@ -180,9 +174,9 @@ fn paste_directory_path_remains_as_text() { #[test] fn paste_multiple_nonexistent_paths_remain_as_text() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let a = tmp.path().join("a.png"); - let b = tmp.path().join("b.wav"); + let tmp = TestWorkspace::new(); + let a = tmp.path("a.png"); + let b = tmp.path("b.wav"); let input = format!("{}\n{}", a.display(), b.display()); app.paste(&input); @@ -194,8 +188,8 @@ fn paste_multiple_nonexistent_paths_remain_as_text() { #[test] fn paste_existing_regular_file_becomes_media_and_clears_text() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png"); app.paste(img.to_str().unwrap()); @@ -207,9 +201,9 @@ fn paste_existing_regular_file_becomes_media_and_clears_text() { #[test] fn paste_mixed_valid_and_missing_media_keeps_text_and_adds_nothing() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let valid = create_temp_file(&tmp, "photo.png", b"img"); - let missing = tmp.path().join("gone.wav"); + let tmp = TestWorkspace::new(); + let valid = tmp.write("photo.png", b"img"); + let missing = tmp.path("gone.wav"); let input = format!("{}\n{}", valid.display(), missing.display()); app.paste(&input); @@ -221,9 +215,9 @@ fn paste_mixed_valid_and_missing_media_keeps_text_and_adds_nothing() { #[test] fn paste_mixed_valid_media_and_directory_keeps_text_and_adds_nothing() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let valid = create_temp_file(&tmp, "photo.png", b"img"); - let dir = tmp.path().join("clips.png"); + let tmp = TestWorkspace::new(); + let valid = tmp.write("photo.png", b"img"); + let dir = tmp.path("clips.png"); std::fs::create_dir(&dir).unwrap(); let input = format!("{}\n{}", valid.display(), dir.display()); @@ -236,9 +230,9 @@ fn paste_mixed_valid_media_and_directory_keeps_text_and_adds_nothing() { #[test] fn paste_multiple_dropped_files_adds_all() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "a.png", b"img"); - let audio = create_temp_file(&tmp, "b.wav", b"audio"); + let tmp = TestWorkspace::new(); + let img = tmp.write("a.png", b"img"); + let audio = tmp.write("b.wav", b"audio"); let input = format!("{}\n{}", img.display(), audio.display()); app.paste(&input); @@ -249,8 +243,8 @@ fn paste_multiple_dropped_files_adds_all() { #[test] fn duplicate_dropped_media_not_added_twice() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"img"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"img"); let path_str = img.to_str().unwrap().to_string(); app.paste(&path_str); @@ -262,8 +256,8 @@ fn duplicate_dropped_media_not_added_twice() { #[test] fn media_only_submit_sends_with_content_blocks() { let mut app = make_app_with_prompt_capabilities(media_caps()); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -284,8 +278,8 @@ fn media_only_submit_sends_with_content_blocks() { #[test] fn submit_with_text_and_media_merges_both() { let mut app = make_app_with_prompt_capabilities(media_caps()); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -306,8 +300,8 @@ fn submit_with_text_and_media_merges_both() { #[test] fn submit_clears_pending_media() { let mut app = make_app_with_prompt_capabilities(media_caps()); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png"); app.paste(img.to_str().unwrap()); app.key(key(KeyCode::Enter)); @@ -321,9 +315,9 @@ fn submit_clears_pending_media() { #[test] fn backspace_on_empty_composer_removes_last_dropped_media() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img1 = create_temp_file(&tmp, "a.png", b"a"); - let img2 = create_temp_file(&tmp, "b.png", b"b"); + let tmp = TestWorkspace::new(); + let img1 = tmp.write("a.png", b"a"); + let img2 = tmp.write("b.png", b"b"); app.paste(img1.to_str().unwrap()); app.paste(img2.to_str().unwrap()); @@ -340,8 +334,8 @@ fn backspace_on_empty_composer_removes_last_dropped_media() { #[test] fn backspace_does_not_remove_media_when_text_present() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"img"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"img"); app.paste(img.to_str().unwrap()); app.type_text("x"); @@ -354,8 +348,8 @@ fn backspace_does_not_remove_media_when_text_present() { #[test] fn attachment_chips_render_in_layout() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"img"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"img"); app.paste(img.to_str().unwrap()); @@ -373,8 +367,8 @@ fn attachment_chips_render_in_layout() { fn agent_rejects_image_when_capability_missing() { let caps = acp::PromptCapabilities::new().image(None).audio(acp::PromptAudioCapabilities::new()); let mut app = make_app_with_prompt_capabilities(caps); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -392,8 +386,8 @@ fn agent_rejects_image_when_capability_missing() { fn agent_rejects_audio_when_capability_missing() { let caps = acp::PromptCapabilities::new().image(acp::PromptImageCapabilities::new()).audio(None); let mut app = make_app_with_prompt_capabilities(caps); - let tmp = TempDir::new().unwrap(); - let audio = create_temp_file(&tmp, "note.wav", b"fake wav data"); + let tmp = TestWorkspace::new(); + let audio = tmp.write("note.wav", b"fake wav data"); app.executor_mut().filesystem_mut().write_file(&audio, b"fake wav data"); app.paste(audio.to_str().unwrap()); @@ -414,8 +408,8 @@ fn selected_model_rejects_image() { vec![model_select_option("gpt:no-vision", "GPT No Vision", false, false)], )]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -435,8 +429,8 @@ fn missing_model_metadata_rejects_media() { let config = vec![image_model_config("unknown-model", vec![model_select_option("known-model", "Known", true, true)])]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -458,8 +452,8 @@ fn supported_media_sends_blocks() { vec![model_select_option("claude:vision", "Claude Vision", true, true)], )]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -479,8 +473,8 @@ fn supported_media_sends_blocks() { #[test] fn sync_prompt_failure_resets_busy_state() { let mut app = make_failable_app_with_caps(media_caps()); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.paste(img.to_str().unwrap()); app.key(key(KeyCode::Enter)); @@ -524,8 +518,8 @@ fn submit_is_blocked_when_composer_empty_without_media() { #[test] fn clear_command_also_clears_pending_media() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"img"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"img"); app.paste(img.to_str().unwrap()); app.type_text("/clear"); @@ -539,8 +533,8 @@ fn clear_command_also_clears_pending_media() { #[test] fn paste_with_file_uri_parses_correctly() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "image.png", b"img"); + let tmp = TestWorkspace::new(); + let img = tmp.write("image.png", b"img"); let uri = format!("file://{}", img.display()); app.paste(&uri); @@ -552,8 +546,8 @@ fn paste_with_file_uri_parses_correctly() { #[test] fn paste_with_percent_decoded_file_uri() { let mut app = make_app(); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "my image.png", b"png"); + let tmp = TestWorkspace::new(); + let img = tmp.write("my image.png", b"png"); let unencoded_path = img.to_str().unwrap(); let encoded_path = unencoded_path.replace(' ', "%20"); let uri = format!("file://{encoded_path}"); @@ -574,8 +568,8 @@ fn selected_model_rejects_audio() { vec![model_select_option("gpt:no-audio", "GPT No Audio", true, false)], )]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let audio = create_temp_file(&tmp, "note.wav", b"fake wav data"); + let tmp = TestWorkspace::new(); + let audio = tmp.write("note.wav", b"fake wav data"); app.executor_mut().filesystem_mut().write_file(&audio, b"fake wav data"); app.paste(audio.to_str().unwrap()); @@ -599,8 +593,8 @@ fn selected_model_rejects_image_grouped() { )]; let config = vec![grouped_model_config("grouped:no-vision", groups)]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -624,8 +618,8 @@ fn selected_model_rejects_audio_grouped() { )]; let config = vec![grouped_model_config("grouped:no-audio", groups)]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let audio = create_temp_file(&tmp, "note.wav", b"fake wav data"); + let tmp = TestWorkspace::new(); + let audio = tmp.write("note.wav", b"fake wav data"); app.executor_mut().filesystem_mut().write_file(&audio, b"fake wav data"); app.paste(audio.to_str().unwrap()); @@ -656,8 +650,8 @@ fn comma_separated_multi_model_rejects_image() { ]; let config = vec![grouped_model_config("claude:sonnet,gpt:text-only", groups)]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -682,8 +676,8 @@ fn comma_separated_multi_model_sends_when_all_support_media() { ]; let config = vec![grouped_model_config("claude:sonnet,deepseek:r1", groups)]; let mut app = make_app_with_caps_and_config(caps, config); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -704,8 +698,8 @@ fn comma_separated_multi_model_sends_when_all_support_media() { fn unsupported_media_returns_the_text_to_the_composer() { let caps = acp::PromptCapabilities::new().image(None).audio(None); let mut app = make_app_with_prompt_capabilities(caps); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -729,8 +723,8 @@ fn unsupported_media_returns_the_text_to_the_composer() { fn sync_failure_returns_the_text_to_the_composer() { let caps = media_caps(); let mut app = make_failable_app_with_caps(caps); - let tmp = TempDir::new().unwrap(); - let img = create_temp_file(&tmp, "photo.png", b"fake png data"); + let tmp = TestWorkspace::new(); + let img = tmp.write("photo.png", b"fake png data"); app.executor_mut().filesystem_mut().write_file(&img, b"fake png data"); app.paste(img.to_str().unwrap()); @@ -752,10 +746,9 @@ fn sync_failure_returns_the_text_to_the_composer() { const ONE_MIB: usize = 1024 * 1024; const TEN_MIB: usize = 10 * 1024 * 1024; -fn write_temp(name: &str, bytes: &[u8]) -> (TempDir, PathBuf) { - let dir = TempDir::new().unwrap(); - let path = dir.path().join(name); - std::fs::write(&path, bytes).unwrap(); +fn write_temp(name: &str, bytes: &[u8]) -> (TestWorkspace, PathBuf) { + let dir = TestWorkspace::new(); + let path = dir.write(name, bytes); (dir, path) } @@ -996,10 +989,10 @@ fn unsupported_extension_text_is_embedded_as_resource() { #[test] fn multi_attachment_preserves_order_with_truncated_text_between_media() { - let dir = TempDir::new().unwrap(); - let img = create_temp_file(&dir, "photo.png", b"png bytes"); - let big = create_temp_file(&dir, "big.txt", &vec![b'a'; ONE_MIB + 5]); - let wav = create_temp_file(&dir, "note.wav", b"wav bytes"); + let dir = TestWorkspace::new(); + let img = dir.write("photo.png", b"png bytes"); + let big = dir.write("big.txt", vec![b'a'; ONE_MIB + 5]); + let wav = dir.write("note.wav", b"wav bytes"); let attachments = vec![ PromptAttachment { path: img, display_name: "photo.png".to_string() }, PromptAttachment { path: big, display_name: "big.txt".to_string() }, diff --git a/crates/wisp/tests/tui/support.rs b/crates/wisp/tests/tui/support.rs index 71a34dc1d..87fb9b4a6 100644 --- a/crates/wisp/tests/tui/support.rs +++ b/crates/wisp/tests/tui/support.rs @@ -23,6 +23,7 @@ pub(crate) use std::sync::Arc; pub(crate) use std::time::{Duration, Instant}; pub(crate) use tempfile::TempDir; pub(crate) use tokio::task::LocalSet; +pub(crate) use utils::testing::TestWorkspace; pub(crate) use wisp::app::ForegroundOperation; pub(crate) use wisp::command::{AgentCommand, Command, CommandResult, FilesystemCommand, PromptRejection}; pub(crate) use wisp::testing::{ From 0d8d2374ea666bbffa8ff81b835395a4a99c3303 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 17:29:23 -0700 Subject: [PATCH 09/26] refactor: share git test helpers through utils::testing Add git, git_init and git_commit to utils::testing, plus TestWorkspace::git_init / commit / git for repos at the workspace root, and build every crate's git fixtures on them. - git_init pins the branch to main, sets a test identity and disables templates, hooks and commit signing so host git config can't leak in. - aether-evals' git_repo tests drop 27 raw git commands that ignored the exit status; every helper call now asserts success. - wisp drops its Repo support module for TestWorkspace. - aether-cli keeps only init_repo, makes test clones with StdCopyCloner like the product does, and compiles workspace::testing only under test or the testing feature. --- Cargo.lock | 1 + crates/aether-cli/Cargo.toml | 2 +- crates/aether-cli/src/workspace/git.rs | 10 +- crates/aether-cli/src/workspace/mod.rs | 1 + crates/aether-cli/src/workspace/testing.rs | 27 +--- .../tests/integration/acp_git_diff.rs | 3 +- .../tests/integration/acp_workspace_move.rs | 23 ++-- crates/aether-evals/Cargo.toml | 3 + crates/aether-evals/src/evals/workspace.rs | 46 ++----- crates/aether-evals/src/git_repo.rs | 124 ++++-------------- crates/utils/src/testing.rs | 38 ++++++ crates/wisp/tests/git_contract.rs | 54 ++++---- crates/wisp/tests/support/git_repo.rs | 47 ------- 13 files changed, 132 insertions(+), 247 deletions(-) delete mode 100644 crates/wisp/tests/support/git_repo.rs diff --git a/Cargo.lock b/Cargo.lock index 273d6a4df..32911433a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,6 +201,7 @@ version = "0.3.20" dependencies = [ "aether-agent-core", "aether-llm", + "aether-utils", "async-stream", "futures", "schemars 1.2.2", diff --git a/crates/aether-cli/Cargo.toml b/crates/aether-cli/Cargo.toml index 7df0ccb4d..73e0c8657 100644 --- a/crates/aether-cli/Cargo.toml +++ b/crates/aether-cli/Cargo.toml @@ -29,7 +29,7 @@ name = "aether" path = "src/main.rs" [features] -testing = ["acp_utils/testing", "mcp_utils/testing", "aether-core/testing"] +testing = ["acp_utils/testing", "mcp_utils/testing", "aether-core/testing", "utils/testing"] [dependencies] aether-auth = { path = "../aether-auth", version = "0.1.17", features = ["keyring"] } diff --git a/crates/aether-cli/src/workspace/git.rs b/crates/aether-cli/src/workspace/git.rs index fcd2689b2..734bca31d 100644 --- a/crates/aether-cli/src/workspace/git.rs +++ b/crates/aether-cli/src/workspace/git.rs @@ -142,9 +142,11 @@ fn trimmed(output: &Output) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::workspace::testing::{clone_repo, git, init_repo}; + use crate::workspace::DirectoryCloner; + use crate::workspace::testing::{StdCopyCloner, init_repo}; use std::fs; use tempfile::TempDir; + use utils::testing::git_commit; #[test] fn repo_root_resolves_from_subdirectory() { @@ -159,10 +161,10 @@ mod tests { fn root_commit_hash_is_stable_across_clones() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), "repo"); - let clone = clone_repo(&repo, tmp.path().join("clone")); + let clone = tmp.path().join("clone"); + StdCopyCloner.clone_dir(&repo, &clone).unwrap(); fs::write(clone.join("extra.txt"), "diverged\n").unwrap(); - git(&clone, &["add", "."]); - git(&clone, &["commit", "-m", "diverge"]); + git_commit(&clone, "diverge"); let original = root_commit_hash(&repo).unwrap(); assert_ne!(original, ""); diff --git a/crates/aether-cli/src/workspace/mod.rs b/crates/aether-cli/src/workspace/mod.rs index 7d8a2658b..d4ae892f2 100644 --- a/crates/aether-cli/src/workspace/mod.rs +++ b/crates/aether-cli/src/workspace/mod.rs @@ -6,6 +6,7 @@ use thiserror::Error; mod git; mod registry; +#[cfg(any(test, feature = "testing"))] #[doc(hidden)] pub mod testing; diff --git a/crates/aether-cli/src/workspace/testing.rs b/crates/aether-cli/src/workspace/testing.rs index e79143940..c89f51783 100644 --- a/crates/aether-cli/src/workspace/testing.rs +++ b/crates/aether-cli/src/workspace/testing.rs @@ -2,6 +2,7 @@ use super::{CloneError, DirectoryCloner}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use utils::testing::{git_commit, git_init}; /// Platform-agnostic directory cloner for tests: a plain recursive `cp -R`. pub struct StdCopyCloner; @@ -30,30 +31,8 @@ impl DirectoryCloner for StdCopyCloner { pub fn init_repo(parent: &Path, name: &str) -> PathBuf { let repo = parent.join(name); fs::create_dir_all(&repo).unwrap(); - git(&repo, &["init"]); - git(&repo, &["config", "user.email", "test@example.com"]); - git(&repo, &["config", "user.name", "Test"]); fs::write(repo.join("committed.txt"), format!("original {name}\n")).unwrap(); - git(&repo, &["add", "."]); - git(&repo, &["commit", "-m", "initial"]); + git_init(&repo); + git_commit(&repo, "initial"); repo } - -pub fn clone_repo(src: &Path, dst: PathBuf) -> PathBuf { - let output = Command::new("git").arg("clone").arg(src).arg(&dst).output().expect("git clone spawns"); - assert!(output.status.success(), "git clone failed: {}", String::from_utf8_lossy(&output.stderr)); - git(&dst, &["config", "user.email", "test@example.com"]); - git(&dst, &["config", "user.name", "Test"]); - dst -} - -pub fn git(repo: &Path, args: &[&str]) { - let output = Command::new("git").arg("-C").arg(repo).args(args).output().expect("git spawns"); - assert!(output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr)); -} - -pub fn git_status(repo: &Path) -> String { - let output = - Command::new("git").arg("-C").arg(repo).args(["status", "--porcelain"]).output().expect("git status spawns"); - String::from_utf8_lossy(&output.stdout).trim().to_string() -} diff --git a/crates/aether-cli/tests/integration/acp_git_diff.rs b/crates/aether-cli/tests/integration/acp_git_diff.rs index fa6c8c231..5c80a0808 100644 --- a/crates/aether-cli/tests/integration/acp_git_diff.rs +++ b/crates/aether-cli/tests/integration/acp_git_diff.rs @@ -1,11 +1,12 @@ use acp_utils::notifications::{GitDiffClosePayload, GitDiffCommandPayload, WorkspaceStatusPayload}; use aether_cli::acp::testing::AcpTestHarness; -use aether_cli::workspace::testing::{git, init_repo}; +use aether_cli::workspace::testing::init_repo; use agent_client_protocol::schema::v2::AbsolutePath; use clankerdiff_core::{DiffScope, RepositoryAction}; use clankerdiff_protocol::client::ClientCommand; use clankerdiff_protocol::shared::{DocumentUpdate, Event, FileEntry, LIVE_PROTOCOL_VERSION}; use std::fs; +use utils::testing::git; #[tokio::test(flavor = "current_thread")] async fn git_diff_open_publishes_snapshot_over_duplex() { diff --git a/crates/aether-cli/tests/integration/acp_workspace_move.rs b/crates/aether-cli/tests/integration/acp_workspace_move.rs index 266289745..b26d15997 100644 --- a/crates/aether-cli/tests/integration/acp_workspace_move.rs +++ b/crates/aether-cli/tests/integration/acp_workspace_move.rs @@ -2,11 +2,13 @@ use acp_utils::notifications::{ WorkspaceListParams, WorkspaceListResponse, WorkspaceMoveParams, WorkspaceMoveResponse, WorkspaceMoveTarget, }; use aether_cli::acp::testing::AcpTestHarness; -use aether_cli::workspace::testing::{clone_repo, git, git_status, init_repo}; +use aether_cli::workspace::DirectoryCloner; +use aether_cli::workspace::testing::{StdCopyCloner, init_repo}; use agent_client_protocol::schema::v2::{ AbsolutePath, ListSessionsRequest, PromptRequest, ResumeSessionRequest, StopReason, }; use std::fs; +use utils::testing::{git, git_commit}; #[tokio::test(flavor = "current_thread")] async fn workspace_list_registers_source_repo_and_marks_it_current() { @@ -46,7 +48,7 @@ async fn workspace_move_to_new_sibling_clones_changes_and_resets_source() { assert_eq!(response.new_cwd, clone.canonicalize().unwrap()); assert_eq!(fs::read_to_string(clone.join("committed.txt")).unwrap(), "modified\n"); assert_eq!(fs::read_to_string(clone.join("untracked.txt")).unwrap(), "new file\n"); - assert!(git_status(&repo).is_empty(), "source should be clean after move"); + assert!(git(&repo, &["status", "--porcelain"]).is_empty(), "source should be clean after move"); let sessions = harness.client_cx.send_request(ListSessionsRequest::new()).block_task().await.expect("list sessions"); @@ -61,7 +63,8 @@ async fn workspace_move_to_existing_clean_target_applies_changes() { AcpTestHarness::run(|harness| async move { let tmp = tempfile::tempdir().unwrap(); let repo = init_repo(tmp.path(), "repo"); - let clone = clone_repo(&repo, tmp.path().join("clone")); + let clone = tmp.path().join("clone"); + StdCopyCloner.clone_dir(&repo, &clone).unwrap(); harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo); fs::write(repo.join("committed.txt"), "edited\n").unwrap(); @@ -72,7 +75,7 @@ async fn workspace_move_to_existing_clean_target_applies_changes() { assert_eq!(response.new_cwd, clone.canonicalize().unwrap()); assert_eq!(fs::read_to_string(clone.join("committed.txt")).unwrap(), "edited\n"); - assert!(git_status(&repo).is_empty(), "source should be clean after move"); + assert!(git(&repo, &["status", "--porcelain"]).is_empty(), "source should be clean after move"); }) .await; } @@ -82,12 +85,12 @@ async fn workspace_move_to_existing_target_on_different_head_fails_actionably() AcpTestHarness::run(|harness| async move { let tmp = tempfile::tempdir().unwrap(); let repo = init_repo(tmp.path(), "repo"); - let clone = clone_repo(&repo, tmp.path().join("clone")); + let clone = tmp.path().join("clone"); + StdCopyCloner.clone_dir(&repo, &clone).unwrap(); harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo); fs::write(clone.join("target-only.txt"), "target commit\n").unwrap(); - git(&clone, &["add", "."]); - git(&clone, &["commit", "-m", "target diverges"]); + git_commit(&clone, "target diverges"); fs::write(repo.join("committed.txt"), "edited\n").unwrap(); let error = move_workspace(&harness, "s1", WorkspaceMoveTarget::Existing { path: clone }) @@ -105,7 +108,8 @@ async fn workspace_move_to_dirty_target_fails_and_leaves_source_untouched() { AcpTestHarness::run(|harness| async move { let tmp = tempfile::tempdir().unwrap(); let repo = init_repo(tmp.path(), "repo"); - let clone = clone_repo(&repo, tmp.path().join("clone")); + let clone = tmp.path().join("clone"); + StdCopyCloner.clone_dir(&repo, &clone).unwrap(); harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo); fs::write(repo.join("committed.txt"), "edited\n").unwrap(); @@ -184,8 +188,7 @@ async fn workspace_move_relocates_session_to_matching_subdirectory() { let sub = repo.join("sub"); fs::create_dir_all(&sub).unwrap(); fs::write(sub.join("file.txt"), "content\n").unwrap(); - git(&repo, &["add", "."]); - git(&repo, &["commit", "-m", "add subdir"]); + git_commit(&repo, "add subdir"); harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &sub); let response = move_workspace(&harness, "s1", WorkspaceMoveTarget::New { name: "repo-2".to_string() }) diff --git a/crates/aether-evals/Cargo.toml b/crates/aether-evals/Cargo.toml index d1d9525b4..f0cedc97e 100644 --- a/crates/aether-evals/Cargo.toml +++ b/crates/aether-evals/Cargo.toml @@ -29,6 +29,9 @@ aether-core = { package = "aether-agent-core", path = "../aether-core", version llm = { package = "aether-llm", path = "../llm", version = "0.9.13" } testcontainers = { workspace = true } +[dev-dependencies] +utils = { package = "aether-utils", path = "../utils", features = ["testing"], version = "0.2.19" } + [lib] name = "aether_evals" path = "src/lib.rs" diff --git a/crates/aether-evals/src/evals/workspace.rs b/crates/aether-evals/src/evals/workspace.rs index 25e32ea23..bfc7dc87f 100644 --- a/crates/aether-evals/src/evals/workspace.rs +++ b/crates/aether-evals/src/evals/workspace.rs @@ -227,8 +227,7 @@ fn copy_dir_contents(src: &Path, dst: &Path) -> std::io::Result<()> { mod tests { use super::*; use std::fs::{read_to_string, remove_dir_all}; - use std::process::Command; - use tempfile::TempDir; + use utils::testing::{TestWorkspace, git}; #[test] fn persist_reports_root_and_path_for_local_workspace() { @@ -248,7 +247,7 @@ mod tests { let bundle_path = bundle_dir.path().join("repo.bundle"); create_git_bundle( &GitRepoSpec { - url: format!("file://{}", repo.path().display()), + url: format!("file://{}", repo.root().display()), start_commit: start.clone(), gold_commit: gold.clone(), subdir: None, @@ -283,7 +282,7 @@ mod tests { fn capture_git_diffs_includes_committed_agent_changes() { let (repo, start, gold) = init_repo(); let workspace = Workspace::from_git_repo(GitRepoSpec { - url: format!("file://{}", repo.path().display()), + url: format!("file://{}", repo.root().display()), start_commit: start, gold_commit: gold, subdir: None, @@ -310,7 +309,7 @@ mod tests { fn capture_git_diffs_includes_staged_unstaged_added_and_deleted_changes() { let (repo, start, gold) = init_repo(); let workspace = Workspace::from_git_repo(GitRepoSpec { - url: format!("file://{}", repo.path().display()), + url: format!("file://{}", repo.root().display()), start_commit: start, gold_commit: gold, subdir: None, @@ -351,32 +350,15 @@ mod tests { assert!(matches!(result, Err(WorkspaceError::MissingBundle { .. }))); } - fn init_repo() -> (TempDir, String, String) { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path(); - git(path, &["init", "--initial-branch", "main"]); - git(path, &["config", "user.email", "eval@example.com"]); - git(path, &["config", "user.name", "Eval"]); - - write(path.join("root.txt"), "root v1\n").unwrap(); - write(path.join("deleted.txt"), "before deleted\n").unwrap(); - create_dir_all(path.join("pkg")).unwrap(); - write(path.join("pkg").join("inner.txt"), "inner v1\n").unwrap(); - git(path, &["add", "."]); - git(path, &["commit", "-m", "start"]); - let start = git(path, &["rev-parse", "HEAD"]); - - write(path.join("root.txt"), "root v2\n").unwrap(); - git(path, &["add", "."]); - git(path, &["commit", "-m", "gold"]); - let gold = git(path, &["rev-parse", "HEAD"]); - - (dir, start, gold) - } - - fn git(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git").arg("-C").arg(repo).args(args).output().unwrap(); - assert!(output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr)); - String::from_utf8(output.stdout).unwrap().trim().to_string() + fn init_repo() -> (TestWorkspace, String, String) { + let repo = TestWorkspace::new() + .file("root.txt", "root v1\n") + .file("deleted.txt", "before deleted\n") + .file("pkg/inner.txt", "inner v1\n") + .git_init(); + let start = repo.commit("start"); + repo.write("root.txt", "root v2\n"); + let gold = repo.commit("gold"); + (repo, start, gold) } } diff --git a/crates/aether-evals/src/git_repo.rs b/crates/aether-evals/src/git_repo.rs index 51fc44c8b..47b55ccd8 100644 --- a/crates/aether-evals/src/git_repo.rs +++ b/crates/aether-evals/src/git_repo.rs @@ -173,40 +173,17 @@ pub enum GitRepoError { mod tests { use super::*; use std::fs; + use utils::testing::TestWorkspace; #[test] fn test_git_diff_between_commits() { - let temp_dir = tempfile::tempdir().unwrap(); - let repo_path = temp_dir.path(); - - Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap(); - - fs::write(repo_path.join("test.txt"), "initial content\n").unwrap(); - Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap(); - - let first_commit = String::from_utf8( - Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout, - ) - .unwrap() - .trim() - .to_string(); - - fs::write(repo_path.join("test.txt"), "modified content\n").unwrap(); - fs::write(repo_path.join("new.txt"), "new file\n").unwrap(); - Command::new("git").args(["add", "."]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap(); - - let second_commit = String::from_utf8( - Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout, - ) - .unwrap() - .trim() - .to_string(); - - let git_repo = GitRepo::from_path(repo_path); + let repo = TestWorkspace::new().file("test.txt", "initial content\n").git_init(); + let first_commit = repo.commit("Initial commit"); + repo.write("test.txt", "modified content\n"); + repo.write("new.txt", "new file\n"); + let second_commit = repo.commit("Second commit"); + + let git_repo = GitRepo::from_path(repo.root()); let diff = git_repo.diff(&first_commit, &second_commit).unwrap(); assert!(diff.contains("test.txt"), "Diff should mention test.txt"); @@ -219,38 +196,13 @@ mod tests { #[test] fn test_unified_diff_function() { - let temp_dir = tempfile::tempdir().unwrap(); - let repo_path = temp_dir.path(); - - Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap(); - - fs::write(repo_path.join("test.txt"), "initial content\n").unwrap(); - Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap(); - - let first_commit = String::from_utf8( - Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout, - ) - .unwrap() - .trim() - .to_string(); - - fs::write(repo_path.join("test.txt"), "modified content\n").unwrap(); - Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap(); - Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap(); + let repo = TestWorkspace::new().file("test.txt", "initial content\n").git_init(); + let first_commit = repo.commit("Initial commit"); + repo.write("test.txt", "modified content\n"); + let second_commit = repo.commit("Second commit"); + repo.write("test.txt", "unstaged content\n"); - let second_commit = String::from_utf8( - Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout, - ) - .unwrap() - .trim() - .to_string(); - - fs::write(repo_path.join("test.txt"), "unstaged content\n").unwrap(); - - let git_repo = GitRepo::from_path(repo_path); + let git_repo = GitRepo::from_path(repo.root()); let diff = git_repo.diff_range(&first_commit, Some(&second_commit)).unwrap(); assert!(diff.contains("modified content") || diff.contains("+modified content")); @@ -264,43 +216,15 @@ mod tests { #[test] fn test_blobless_clone_and_checkout() { - let source_dir = tempfile::tempdir().unwrap(); - let source_path = source_dir.path(); - - Command::new("git").args(["init"]).current_dir(source_path).output().unwrap(); - Command::new("git") - .args(["config", "user.email", "test@example.com"]) - .current_dir(source_path) - .output() - .unwrap(); - Command::new("git").args(["config", "user.name", "Test User"]).current_dir(source_path).output().unwrap(); - - fs::write(source_path.join("test.txt"), "initial content\n").unwrap(); - Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap(); - Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(source_path).output().unwrap(); - - let first_commit = String::from_utf8( - Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout, - ) - .unwrap() - .trim() - .to_string(); - - fs::write(source_path.join("test.txt"), "modified content\n").unwrap(); - Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap(); - Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(source_path).output().unwrap(); - - let second_commit = String::from_utf8( - Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout, - ) - .unwrap() - .trim() - .to_string(); - - let clone_dir = tempfile::tempdir().unwrap(); - let repo = GitRepo::clone(source_path.to_str().unwrap(), clone_dir.path(), CloneMode::Blobless).unwrap(); - - let entries: Vec<_> = fs::read_dir(clone_dir.path()) + let source = TestWorkspace::new().file("test.txt", "initial content\n").git_init(); + let first_commit = source.commit("Initial commit"); + source.write("test.txt", "modified content\n"); + let second_commit = source.commit("Second commit"); + + let clone = TestWorkspace::new(); + let repo = GitRepo::clone(source.root(), clone.root(), CloneMode::Blobless).unwrap(); + + let entries: Vec<_> = fs::read_dir(clone.root()) .unwrap() .filter_map(std::result::Result::ok) .filter(|e| e.file_name() != ".git") @@ -309,7 +233,7 @@ mod tests { repo.checkout(&first_commit).unwrap(); - let content = fs::read_to_string(clone_dir.path().join("test.txt")).unwrap(); + let content = fs::read_to_string(clone.path("test.txt")).unwrap(); assert_eq!(content, "initial content\n"); let diff = repo.diff(&first_commit, &second_commit).unwrap(); diff --git a/crates/utils/src/testing.rs b/crates/utils/src/testing.rs index 2ce67e007..7679932ae 100644 --- a/crates/utils/src/testing.rs +++ b/crates/utils/src/testing.rs @@ -2,6 +2,7 @@ use std::fs; #[cfg(unix)] use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; +use std::process::Command; use tempfile::TempDir; pub struct TestWorkspace { @@ -63,4 +64,41 @@ impl TestWorkspace { pub fn path_string(&self, path: impl AsRef) -> String { self.path(path).to_string_lossy().into_owned() } + + pub fn git_init(self) -> Self { + git_init(self.root()); + self + } + + pub fn commit(&self, message: &str) -> String { + git_commit(self.root(), message) + } + + pub fn git(&self, args: &[&str]) -> String { + git(self.root(), args) + } +} + +pub fn git_init(dir: &Path) { + git(dir, &["-c", "init.templateDir=", "init", "--quiet", "--initial-branch=main"]); + for (key, value) in [ + ("user.name", "Test"), + ("user.email", "test@example.com"), + ("commit.gpgsign", "false"), + ("core.hooksPath", "/dev/null"), + ] { + git(dir, &["config", key, value]); + } +} + +pub fn git_commit(dir: &Path, message: &str) -> String { + git(dir, &["add", "-A"]); + git(dir, &["commit", "--quiet", "-m", message]); + git(dir, &["rev-parse", "HEAD"]) +} + +pub fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git").arg("-C").arg(dir).args(args).output().expect("failed to spawn git"); + assert!(output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8_lossy(&output.stdout).trim().to_owned() } diff --git a/crates/wisp/tests/git_contract.rs b/crates/wisp/tests/git_contract.rs index d49b85b98..254cceaff 100644 --- a/crates/wisp/tests/git_contract.rs +++ b/crates/wisp/tests/git_contract.rs @@ -3,15 +3,17 @@ //! `FakeGit`; these only guard that the subprocess boundary and its parsers //! still agree with actual `git` output. -#[path = "support/git_repo.rs"] -mod git_repo; -use git_repo::Repo; - use clankerdiff_git::GitRepository; use clankerdiff_ratatui::diff::{PatchLineKind, RepoPath, RepositoryAction}; use tempfile::TempDir; +use utils::testing::TestWorkspace; use wisp::git_review::{DiffDocument, DiffScope, FileDiff, FileStatus, RemoteErrorCode, StageState}; +async fn load(repo: &TestWorkspace, scope: DiffScope) -> DiffDocument { + let repository = GitRepository::discover(repo.root()).await.expect("discover repository"); + (*repository.snapshot_with_sources(scope).await.expect("load repository").document).clone() +} + fn file<'a>(document: &'a DiffDocument, path: &str) -> &'a FileDiff { document .files @@ -37,11 +39,11 @@ async fn real_and_fake_git_report_the_same_non_repository_errors() { #[tokio::test] async fn real_and_fake_git_report_the_same_commit_errors() { - let repo = Repo::init(); - let mut fake = wisp::testing::FakeGit::new(&repo.root); + let repo = TestWorkspace::new().git_init(); + let mut fake = wisp::testing::FakeGit::new(repo.root()); for message in [" ", "nothing staged"] { let action = RepositoryAction::Commit { message: message.into() }; - let repository = GitRepository::discover(&repo.root).await.unwrap(); + let repository = GitRepository::discover(repo.root()).await.unwrap(); let real = repository.apply(action.clone()).await.unwrap_err(); let fake = fake.apply(action).unwrap_err(); assert_eq!(fake.to_string(), real.to_string()); @@ -50,19 +52,19 @@ async fn real_and_fake_git_report_the_same_commit_errors() { #[tokio::test] async fn load_parses_modified_staged_and_untracked_files() { - let repo = Repo::init(); - repo.write("src/lib.rs", "fn one() {}\nfn two() {}\n"); - repo.write("staged.txt", "original\n"); - repo.git(&["add", "-A"]); - repo.git(&["commit", "-m", "init"]); + let repo = TestWorkspace::new() + .file("src/lib.rs", "fn one() {}\nfn two() {}\n") + .file("staged.txt", "original\n") + .git_init(); + repo.commit("init"); repo.write("src/lib.rs", "fn one() {}\nfn two() { changed(); }\n"); repo.write("staged.txt", "staged change\n"); repo.git(&["add", "staged.txt"]); repo.write("untracked.txt", "brand new\n"); - let document = repo.load(DiffScope::Both).await; - assert_eq!(document.repo_root, repo.root.to_string_lossy()); + let document = load(&repo, DiffScope::Both).await; + assert_eq!(document.repo_root, repo.root().canonicalize().unwrap().to_string_lossy()); let modified = file(&document, "src/lib.rs"); assert_eq!(modified.status, FileStatus::Modified); @@ -101,39 +103,35 @@ async fn load_parses_modified_staged_and_untracked_files() { #[tokio::test] async fn stage_commit_round_trip_reaches_a_clean_tree() { - let repo = Repo::init(); - repo.write("file.txt", "one\n"); - repo.git(&["add", "-A"]); - repo.git(&["commit", "-m", "init"]); + let repo = TestWorkspace::new().file("file.txt", "one\n").git_init(); + repo.commit("init"); repo.write("file.txt", "two\n"); - let repository = GitRepository::discover(&repo.root).await.unwrap(); + let repository = GitRepository::discover(repo.root()).await.unwrap(); repository.apply(RepositoryAction::StagePaths(vec![RepoPath::new("file.txt").unwrap()])).await.unwrap(); - let document = repo.load(DiffScope::Both).await; + let document = load(&repo, DiffScope::Both).await; assert_eq!(file(&document, "file.txt").staged, StageState::Staged); repository.apply(RepositoryAction::UnstagePaths(vec![RepoPath::new("file.txt").unwrap()])).await.unwrap(); - let document = repo.load(DiffScope::Both).await; + let document = load(&repo, DiffScope::Both).await; assert_eq!(file(&document, "file.txt").staged, StageState::Unstaged); repository.apply(RepositoryAction::StageAll).await.unwrap(); repository.apply(RepositoryAction::Commit { message: "update".to_string() }).await.unwrap(); - let document = repo.load(DiffScope::Both).await; + let document = load(&repo, DiffScope::Both).await; assert!(document.files.is_empty(), "committed tree must be clean, found {:?}", paths(&document)); } #[tokio::test] async fn renames_and_binary_files_survive_parsing() { - let repo = Repo::init(); - repo.write("old_name.rs", "fn kept() {}\n"); - repo.write("image.bin", [0u8, 159, 146, 150]); - repo.git(&["add", "-A"]); - repo.git(&["commit", "-m", "init"]); + let repo = + TestWorkspace::new().file("old_name.rs", "fn kept() {}\n").file("image.bin", [0u8, 159, 146, 150]).git_init(); + repo.commit("init"); repo.git(&["mv", "old_name.rs", "new_name.rs"]); repo.write("image.bin", [255u8, 216, 255, 0]); - let document = repo.load(DiffScope::Both).await; + let document = load(&repo, DiffScope::Both).await; let renamed = file(&document, "new_name.rs"); assert_eq!(renamed.status, FileStatus::Renamed); diff --git a/crates/wisp/tests/support/git_repo.rs b/crates/wisp/tests/support/git_repo.rs deleted file mode 100644 index 02161c94b..000000000 --- a/crates/wisp/tests/support/git_repo.rs +++ /dev/null @@ -1,47 +0,0 @@ -use clankerdiff_git::GitRepository; -use std::path::PathBuf; -use std::process::Command; -use tempfile::TempDir; -use wisp::git_review::{DiffDocument, DiffScope}; - -pub struct Repo { - _dir: TempDir, - pub root: PathBuf, -} - -impl Repo { - pub fn init() -> Self { - let dir = TempDir::new().unwrap(); - let root = dir.path().canonicalize().unwrap(); - let repo = Self { _dir: dir, root }; - let output = Command::new("git") - .current_dir(&repo.root) - .args(["-c", "init.templateDir=", "init", "--initial-branch=main"]) - .output() - .unwrap(); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); - repo.git(&["config", "user.name", "Contract Test"]); - repo.git(&["config", "user.email", "contract@example.com"]); - repo.git(&["config", "commit.gpgsign", "false"]); - repo.git(&["config", "tag.gpgsign", "false"]); - repo.git(&["config", "core.hooksPath", repo.root.join(".git/disabled-hooks").to_str().unwrap()]); - repo - } - - pub fn git(&self, args: &[&str]) -> Vec { - let output = Command::new("git").current_dir(&self.root).args(args).output().unwrap(); - assert!(output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr)); - output.stdout - } - - pub fn write(&self, path: &str, contents: impl AsRef<[u8]>) { - let path = self.root.join(path); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, contents).unwrap(); - } - - pub async fn load(&self, scope: DiffScope) -> DiffDocument { - let repository = GitRepository::discover(&self.root).await.expect("discover repository"); - (*repository.snapshot_with_sources(scope).await.expect("load repository").document).clone() - } -} From 6b910c3ecf98ee3fb7c8b80004e2d31baaca9a38 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 17:55:50 -0700 Subject: [PATCH 10/26] refactor: share one git command wrapper through utils::git Add utils::git with git / git_with_stdin and a GitError carrying the failed command and its stderr, and build aether-cli's workspace git module, aether-evals' GitRepo and the git test helpers on it. - The wrapper returns raw stdout bytes so binary patches and non-UTF-8 paths round-trip intact; callers convert to text where they need it. - aether-evals drops run_git and GitRepoError, whose eight per-operation variants carried nothing beyond the command's stderr. - aether-cli's copy_untracked_files returns io::Error, which WorkspaceError::Io already covers. --- crates/aether-cli/src/workspace/git.rs | 96 +++++--------------------- crates/aether-cli/src/workspace/mod.rs | 2 +- crates/aether-evals/Cargo.toml | 1 + crates/aether-evals/src/error.rs | 4 +- crates/aether-evals/src/git_repo.rs | 92 +++++------------------- crates/aether-evals/src/lib.rs | 1 - crates/utils/src/git.rs | 58 ++++++++++++++++ crates/utils/src/lib.rs | 2 + crates/utils/src/testing.rs | 6 +- 9 files changed, 102 insertions(+), 160 deletions(-) create mode 100644 crates/utils/src/git.rs diff --git a/crates/aether-cli/src/workspace/git.rs b/crates/aether-cli/src/workspace/git.rs index 734bca31d..3b54f7c55 100644 --- a/crates/aether-cli/src/workspace/git.rs +++ b/crates/aether-cli/src/workspace/git.rs @@ -1,54 +1,38 @@ use std::fs; -use std::io::Write; +use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum GitError { - #[error("failed to spawn `git {args}`: {source}")] - Spawn { args: String, source: std::io::Error }, - #[error("`git {args}` failed: {stderr}")] - Failed { args: String, stderr: String }, - #[error("filesystem error: {0}")] - Io(#[from] std::io::Error), -} +use utils::git::{GitError, git, git_with_stdin}; pub fn current_ref(repo: &Path) -> Option { - git(repo, &["branch", "--show-current"]) + git_text(repo, &["branch", "--show-current"]) .ok() - .and_then(|output| trimmed(&output)) - .or_else(|| git(repo, &["rev-parse", "--short", "HEAD"]).ok().and_then(|output| trimmed(&output))) + .filter(|name| !name.is_empty()) + .or_else(|| git_text(repo, &["rev-parse", "--short", "HEAD"]).ok()) } /// Resolves the root of the git working tree containing `path`. pub fn repo_root(path: &Path) -> Result { - let output = git(path, &["rev-parse", "--show-toplevel"])?; - Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim())) + git_text(path, &["rev-parse", "--show-toplevel"]).map(PathBuf::from) } /// Returns the hash of the repository's first root commit, a stable identity /// shared by every clone and copy of the same repository. pub fn root_commit_hash(repo: &Path) -> Result { - let output = git(repo, &["rev-list", "--max-parents=0", "HEAD"])?; - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(stdout.lines().next().unwrap_or_default().trim().to_string()) + Ok(git_text(repo, &["rev-list", "--max-parents=0", "HEAD"])?.lines().next().unwrap_or_default().to_string()) } pub fn head_commit_hash(repo: &Path) -> Result { - let output = git(repo, &["rev-parse", "HEAD"])?; - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + git_text(repo, &["rev-parse", "HEAD"]) } pub fn is_clean(repo: &Path) -> Result { - Ok(git(repo, &["status", "--porcelain=v1", "-z"])?.stdout.is_empty()) + Ok(git(Some(repo), &["status", "--porcelain=v1", "-z"])?.is_empty()) } pub fn untracked_files(repo: &Path) -> Result, GitError> { use std::os::unix::ffi::OsStrExt; - Ok(git(repo, &["ls-files", "--others", "--exclude-standard", "-z"])? - .stdout + Ok(git(Some(repo), &["ls-files", "--others", "--exclude-standard", "-z"])? .split(|b| *b == 0) .filter(|chunk| !chunk.is_empty()) .map(|chunk| PathBuf::from(std::ffi::OsStr::from_bytes(chunk))) @@ -56,7 +40,7 @@ pub fn untracked_files(repo: &Path) -> Result, GitError> { } pub fn diff_head(repo: &Path) -> Result, GitError> { - Ok(git(repo, &["diff", "HEAD", "--binary", "--no-color"])?.stdout) + git(Some(repo), &["diff", "HEAD", "--binary", "--no-color"]) } pub fn apply_patch(repo: &Path, patch: &[u8]) -> Result<(), GitError> { @@ -64,11 +48,11 @@ pub fn apply_patch(repo: &Path, patch: &[u8]) -> Result<(), GitError> { return Ok(()); } - git_with_stdin(repo, &["apply", "--binary", "--whitespace=nowarn"], patch)?; + git_with_stdin(Some(repo), &["apply", "--binary", "--whitespace=nowarn"], patch)?; Ok(()) } -pub fn copy_untracked_files(src: &Path, dst: &Path, files: &[PathBuf]) -> Result<(), GitError> { +pub fn copy_untracked_files(src: &Path, dst: &Path, files: &[PathBuf]) -> io::Result<()> { for rel in files { let src_file = src.join(rel); let dst_file = dst.join(rel); @@ -84,59 +68,13 @@ pub fn copy_untracked_files(src: &Path, dst: &Path, files: &[PathBuf]) -> Result } pub fn reset_clean(repo: &Path) -> Result<(), GitError> { - git(repo, &["reset", "--hard", "HEAD"])?; - git(repo, &["clean", "-fd"])?; + git(Some(repo), &["reset", "--hard", "HEAD"])?; + git(Some(repo), &["clean", "-fd"])?; Ok(()) } -fn git(repo: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| GitError::Spawn { args: args.join(" "), source: e })?; - - git_output(args, output) -} - -fn git_with_stdin(repo: &Path, args: &[&str], stdin: &[u8]) -> Result { - let args_string = args.join(" "); - let mut child = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| GitError::Spawn { args: args_string.clone(), source: e })?; - - child - .stdin - .take() - .expect("stdin was piped") - .write_all(stdin) - .map_err(|e| GitError::Spawn { args: args_string.clone(), source: e })?; - - let output = child.wait_with_output().map_err(|e| GitError::Spawn { args: args_string, source: e })?; - git_output(args, output) -} - -fn git_output(args: &[&str], output: std::process::Output) -> Result { - if output.status.success() { - Ok(output) - } else { - Err(GitError::Failed { - args: args.join(" "), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - }) - } -} - -fn trimmed(output: &Output) -> Option { - let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (!text.is_empty()).then_some(text) +fn git_text(repo: &Path, args: &[&str]) -> Result { + git(Some(repo), args).map(|stdout| String::from_utf8_lossy(&stdout).trim().to_string()) } #[cfg(test)] diff --git a/crates/aether-cli/src/workspace/mod.rs b/crates/aether-cli/src/workspace/mod.rs index d4ae892f2..ce9278c5a 100644 --- a/crates/aether-cli/src/workspace/mod.rs +++ b/crates/aether-cli/src/workspace/mod.rs @@ -10,9 +10,9 @@ mod registry; #[doc(hidden)] pub mod testing; -use git::GitError; pub(crate) use git::current_ref; use registry::{RegistryError, WorkspaceRegistry}; +use utils::git::GitError; /// Manages the workspaces of a repository: listing the known clones and /// moving uncommitted changes between them. diff --git a/crates/aether-evals/Cargo.toml b/crates/aether-evals/Cargo.toml index f0cedc97e..4343b22de 100644 --- a/crates/aether-evals/Cargo.toml +++ b/crates/aether-evals/Cargo.toml @@ -28,6 +28,7 @@ tempfile = { workspace = true } aether-core = { package = "aether-agent-core", path = "../aether-core", version = "0.9.4" } llm = { package = "aether-llm", path = "../llm", version = "0.9.13" } testcontainers = { workspace = true } +utils = { package = "aether-utils", path = "../utils", version = "0.2.19" } [dev-dependencies] utils = { package = "aether-utils", path = "../utils", features = ["testing"], version = "0.2.19" } diff --git a/crates/aether-evals/src/error.rs b/crates/aether-evals/src/error.rs index 365085283..821b8f35c 100644 --- a/crates/aether-evals/src/error.rs +++ b/crates/aether-evals/src/error.rs @@ -1,7 +1,7 @@ use crate::agents::RunError; -use crate::git_repo::GitRepoError; use std::path::PathBuf; use thiserror::Error; +use utils::git::GitError; #[derive(Debug, Error)] pub enum WorkspaceError { @@ -24,7 +24,7 @@ pub enum WorkspaceError { }, #[error("git workspace setup failed: {0}")] - Git(#[from] GitRepoError), + Git(#[from] GitError), #[error("git workspace subdirectory does not exist: {}", path.display())] MissingSubdir { path: PathBuf }, diff --git a/crates/aether-evals/src/git_repo.rs b/crates/aether-evals/src/git_repo.rs index 47b55ccd8..e576c318d 100644 --- a/crates/aether-evals/src/git_repo.rs +++ b/crates/aether-evals/src/git_repo.rs @@ -1,7 +1,6 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::process::Command; -use thiserror::Error; +use utils::git::{GitError, git}; /// Controls how much of a repository's object set is downloaded during [`GitRepo::clone`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -28,8 +27,8 @@ impl GitRepo { /// Initialize an empty repository at `dest`. #[tracing::instrument(skip(dest))] - pub fn init(dest: &Path) -> Result { - run_git(None, [OsStr::new("init"), dest.as_os_str()], GitRepoError::InitFailed)?; + pub fn init(dest: &Path) -> Result { + git(None, &[OsStr::new("init"), dest.as_os_str()])?; Ok(GitRepo { path: dest.to_path_buf() }) } @@ -40,50 +39,47 @@ impl GitRepo { /// the clone is self-contained (e.g. so it can be bundled, or because `source` is a /// bundle and not a promisor remote). #[tracing::instrument(skip(source, dest))] - pub fn clone(source: impl AsRef, dest: &Path, mode: CloneMode) -> Result { + pub fn clone(source: impl AsRef, dest: &Path, mode: CloneMode) -> Result { let mut args = vec![OsStr::new("clone"), OsStr::new("--no-checkout")]; if mode == CloneMode::Blobless { args.push(OsStr::new("--filter=blob:none")); } args.push(source.as_ref()); args.push(dest.as_os_str()); - run_git(None, args, GitRepoError::CloneFailed)?; + git(None, &args)?; Ok(GitRepo { path: dest.to_path_buf() }) } /// Checkout a specific commit, branch, or tag #[tracing::instrument(skip(self))] - pub fn checkout(&self, reference: &str) -> Result<(), GitRepoError> { - run_git(Some(&self.path), ["checkout", reference], |reason| GitRepoError::CheckoutFailed { - reference: reference.to_string(), - reason, - })?; + pub fn checkout(&self, reference: &str) -> Result<(), GitError> { + self.git(&["checkout", reference])?; Ok(()) } /// Fetch `revs` from `remote` (a named remote or a URL) into this repository. #[tracing::instrument(skip(self))] - pub fn fetch(&self, remote: &str, revs: &[&str]) -> Result<(), GitRepoError> { + pub fn fetch(&self, remote: &str, revs: &[&str]) -> Result<(), GitError> { let mut args = vec!["fetch", remote]; args.extend_from_slice(revs); - run_git(Some(&self.path), args, GitRepoError::FetchFailed)?; + self.git(&args)?; Ok(()) } /// Point a ref at a commit (e.g. to create a branch tip to bundle). #[tracing::instrument(skip(self))] - pub fn update_ref(&self, name: &str, commit: &str) -> Result<(), GitRepoError> { - run_git(Some(&self.path), ["update-ref", name, commit], GitRepoError::UpdateRefFailed)?; + pub fn update_ref(&self, name: &str, commit: &str) -> Result<(), GitError> { + self.git(&["update-ref", name, commit])?; Ok(()) } /// Create a self-contained git bundle at `out` containing the given refs and their /// reachable objects. #[tracing::instrument(skip(self, out))] - pub fn bundle(&self, revs: &[&str], out: &Path) -> Result<(), GitRepoError> { + pub fn bundle(&self, revs: &[&str], out: &Path) -> Result<(), GitError> { let mut args = vec![OsStr::new("bundle"), OsStr::new("create"), out.as_os_str()]; args.extend(revs.iter().map(OsStr::new)); - run_git(Some(&self.path), args, GitRepoError::BundleFailed)?; + self.git(&args)?; Ok(()) } @@ -98,75 +94,25 @@ impl GitRepo { /// * `diff_range("abc123", None)` - changes from commit to working directory /// * `diff_range("HEAD", None)` - unstaged changes (equivalent to `git diff`) #[tracing::instrument(skip(self))] - pub fn diff_range(&self, from_commit: &str, to_commit: Option<&str>) -> Result { + pub fn diff_range(&self, from_commit: &str, to_commit: Option<&str>) -> Result { let range = match to_commit { Some(to) => format!("{from_commit}..{to}"), None => from_commit.to_string(), }; - run_git(Some(&self.path), ["diff", range.as_str()], |reason| GitRepoError::DiffFailed { - from: from_commit.to_string(), - to: to_commit.unwrap_or("working directory").to_string(), - reason, - }) + let stdout = self.git(&["diff", range.as_str()])?; + Ok(String::from_utf8_lossy(&stdout).into_owned()) } /// Get the diff between two commits /// /// Returns the output of `git diff from_commit..to_commit` - pub fn diff(&self, from_commit: &str, to_commit: &str) -> Result { + pub fn diff(&self, from_commit: &str, to_commit: &str) -> Result { self.diff_range(from_commit, Some(to_commit)) } -} - -/// Run `git` (optionally inside `cwd`) and return its stdout. -/// -/// Spawn failures are surfaced as [`GitRepoError::CommandFailed`]; a non-zero exit is mapped -/// to a caller-chosen variant via `on_failure`, which receives the captured stderr. -fn run_git, U: AsRef>( - cwd: Option<&Path>, - args: T, - on_failure: impl FnOnce(String) -> GitRepoError, -) -> Result { - let mut cmd = Command::new("git"); - if let Some(dir) = cwd { - cmd.arg("-C").arg(dir); - } - - let output = - cmd.args(args).output().map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git: {e}")))?; - if !output.status.success() { - return Err(on_failure(String::from_utf8_lossy(&output.stderr).into_owned())); + fn git>(&self, args: &[S]) -> Result, GitError> { + git(Some(&self.path), args) } - - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) -} - -#[derive(Debug, Error)] -pub enum GitRepoError { - #[error("Git command failed: {0}")] - CommandFailed(String), - - #[error("Failed to initialize repository: {0}")] - InitFailed(String), - - #[error("Failed to clone repository: {0}")] - CloneFailed(String), - - #[error("Failed to fetch revisions: {0}")] - FetchFailed(String), - - #[error("Failed to update ref: {0}")] - UpdateRefFailed(String), - - #[error("Failed to create git bundle: {0}")] - BundleFailed(String), - - #[error("Failed to checkout '{reference}': {reason}")] - CheckoutFailed { reference: String, reason: String }, - - #[error("Failed to diff '{from}..{to}': {reason}")] - DiffFailed { from: String, to: String, reason: String }, } #[cfg(test)] diff --git a/crates/aether-evals/src/lib.rs b/crates/aether-evals/src/lib.rs index 4f85206c6..c74ce1e0d 100644 --- a/crates/aether-evals/src/lib.rs +++ b/crates/aether-evals/src/lib.rs @@ -17,7 +17,6 @@ pub use evals::{ DiffStats, GitBundleSpec, GitDiff, GitRepoSpec, RetainedWorkspaceInfo, Task, ToolCall, Transcript, TranscriptError, Workspace, WorkspaceSource, create_git_bundle, }; -pub use git_repo::GitRepoError; pub use judge::{ Judge, JudgeBuilder, JudgeContext, JudgeCriterionResponse, JudgeCriterionSpec, JudgeCriterionSummary, JudgeError, JudgeRubricResponse, JudgeSummary, judge, diff --git a/crates/utils/src/git.rs b/crates/utils/src/git.rs new file mode 100644 index 000000000..7b4e0ee0a --- /dev/null +++ b/crates/utils/src/git.rs @@ -0,0 +1,58 @@ +use std::ffi::OsStr; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output, Stdio}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GitError { + #[error("failed to spawn `git {args}`: {source}")] + Spawn { args: String, source: std::io::Error }, + #[error("`git {args}` failed: {stderr}")] + Failed { args: String, stderr: String }, +} + +pub fn git>(repo: Option<&Path>, args: &[T]) -> Result, GitError> { + let output = command(repo, args).output().map_err(|source| spawn_error(args, source))?; + stdout(args, output) +} + +pub fn git_with_stdin>(repo: Option<&Path>, args: &[T], stdin: &[u8]) -> Result, GitError> { + let mut child = command(repo, args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|source| spawn_error(args, source))?; + child.stdin.take().expect("stdin is piped").write_all(stdin).map_err(|source| spawn_error(args, source))?; + let output = child.wait_with_output().map_err(|source| spawn_error(args, source))?; + stdout(args, output) +} + +fn command>(repo: Option<&Path>, args: &[T]) -> Command { + let mut command = Command::new("git"); + if let Some(repo) = repo { + command.arg("-C").arg(repo); + } + command.args(args); + command +} + +fn stdout>(args: &[T], output: Output) -> Result, GitError> { + if output.status.success() { + Ok(output.stdout) + } else { + Err(GitError::Failed { + args: display(args), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + } +} + +fn spawn_error>(args: &[T], source: std::io::Error) -> GitError { + GitError::Spawn { args: display(args), source } +} + +fn display>(args: &[T]) -> String { + args.iter().map(|arg| arg.as_ref().to_string_lossy()).collect::>().join(" ") +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 9f7907f01..c63942ca7 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -2,6 +2,8 @@ pub mod artifact_review; pub mod display_meta; +#[cfg(not(target_family = "wasm"))] +pub mod git; pub mod mcp_status; pub mod name_pattern; pub mod panic; diff --git a/crates/utils/src/testing.rs b/crates/utils/src/testing.rs index 7679932ae..7fa79d395 100644 --- a/crates/utils/src/testing.rs +++ b/crates/utils/src/testing.rs @@ -2,7 +2,6 @@ use std::fs; #[cfg(unix)] use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; -use std::process::Command; use tempfile::TempDir; pub struct TestWorkspace { @@ -98,7 +97,6 @@ pub fn git_commit(dir: &Path, message: &str) -> String { } pub fn git(dir: &Path, args: &[&str]) -> String { - let output = Command::new("git").arg("-C").arg(dir).args(args).output().expect("failed to spawn git"); - assert!(output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr)); - String::from_utf8_lossy(&output.stdout).trim().to_owned() + let stdout = crate::git::git(Some(dir), args).unwrap_or_else(|error| panic!("{error}")); + String::from_utf8_lossy(&stdout).trim().to_owned() } From 30923eb4180810e740fd9f376db2916085b7ce35 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 18:04:57 -0700 Subject: [PATCH 11/26] refactor: report MCP gateway status as McpServerStatusEntry McpGateway::statuses now returns utils' McpServerStatusEntry instead of its own ServerStatus / ServerState, which mirrored McpServerStatus and McpServerAuthCapability variant for variant. The catalog's 1:1 mapping collapses to setting the deferred-tools flag. mcp-utils gains an aether-utils dependency behind its client feature. --- Cargo.lock | 1 + crates/aether-core/src/mcp/mcp_catalog.rs | 17 +++-------- crates/mcp-utils/Cargo.toml | 2 ++ crates/mcp-utils/src/gateway/connection.rs | 28 ++++++++++--------- crates/mcp-utils/src/gateway/mcp_gateway.rs | 19 ++----------- crates/mcp-utils/src/gateway/mod.rs | 2 +- crates/mcp-utils/tests/gateway.rs | 31 +++++++++++---------- crates/mcp-utils/tests/oauth.rs | 21 +++++++------- 8 files changed, 52 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32911433a..552cd8797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,7 @@ name = "aether-mcp-utils" version = "0.7.13" dependencies = [ "aether-auth", + "aether-utils", "async-stream", "aws-credential-types", "aws-sigv4", diff --git a/crates/aether-core/src/mcp/mcp_catalog.rs b/crates/aether-core/src/mcp/mcp_catalog.rs index 290a6d525..f3744790a 100644 --- a/crates/aether-core/src/mcp/mcp_catalog.rs +++ b/crates/aether-core/src/mcp/mcp_catalog.rs @@ -5,14 +5,14 @@ use crate::core::McpInstructions; use futures::Stream; use llm::ToolDefinition; use mcp_utils::client::{ClientOptions, ToolCall, ToolCallOptions}; -use mcp_utils::gateway::{ConnectedServer, GatewayError, McpGateway, ServerPrompt, ServerState, ServerStatus}; +use mcp_utils::gateway::{ConnectedServer, GatewayError, McpGateway, ServerPrompt}; use mcp_utils::model::{GetPromptResult, Tool}; use mcp_utils::server::{ServerHandle, UnixSocket}; use serde_json::{Map, Value}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; -use utils::mcp_status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry}; +use utils::mcp_status::McpServerStatusEntry; #[derive(Clone)] pub struct McpCatalog { @@ -186,17 +186,8 @@ impl ToolPolicy { Some(if deferred { Tier::Deferred } else { Tier::Direct }) } - fn status_entry(&self, status: ServerStatus) -> McpServerStatusEntry { + fn status_entry(&self, status: McpServerStatusEntry) -> McpServerStatusEntry { let defers_tools = self.exposures.get(&status.name).is_some_and(ToolExposure::defers_tools); - let auth = - if status.can_sign_in { McpServerAuthCapability::OAuth } else { McpServerAuthCapability::Unavailable }; - let state = match status.state { - ServerState::Connecting => McpServerStatus::Connecting, - ServerState::Authenticating => McpServerStatus::Authenticating, - ServerState::NeedsOAuth => McpServerStatus::NeedsOAuth, - ServerState::Failed { error } => McpServerStatus::Failed { error }, - ServerState::Connected { tool_count } => McpServerStatus::Connected { tool_count }, - }; - McpServerStatusEntry::new(status.name, state).with_auth_capability(auth).with_deferred_tools(defers_tools) + status.with_deferred_tools(defers_tools) } } diff --git a/crates/mcp-utils/Cargo.toml b/crates/mcp-utils/Cargo.toml index 51c6a8df8..071c7e6f5 100644 --- a/crates/mcp-utils/Cargo.toml +++ b/crates/mcp-utils/Cargo.toml @@ -37,6 +37,7 @@ client = [ "dep:serde_json", "dep:sse-stream", "dep:tokio-stream", + "dep:utils", "rmcp/transport-streamable-http-client-reqwest", "rmcp/transport-child-process", ] @@ -60,6 +61,7 @@ serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } sse-stream = { workspace = true, optional = true } tokio-stream = { workspace = true, features = ["sync"], optional = true } +utils = { package = "aether-utils", path = "../utils", version = "0.2.19", optional = true } [[test]] name = "client" diff --git a/crates/mcp-utils/src/gateway/connection.rs b/crates/mcp-utils/src/gateway/connection.rs index b916d0dac..aefca92a9 100644 --- a/crates/mcp-utils/src/gateway/connection.rs +++ b/crates/mcp-utils/src/gateway/connection.rs @@ -1,4 +1,4 @@ -use super::{ConnectedServer, GatewayError, ServerSpec, ServerState, ServerStatus}; +use super::{ConnectedServer, GatewayError, ServerSpec}; use crate::client::{ClientError, ClientOptions, McpClient}; use aether_auth::OAuthError; use async_stream::stream; @@ -7,6 +7,7 @@ use std::pin::pin; use tokio::sync::{mpsc, oneshot, watch}; use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; +use utils::mcp_status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry}; pub(super) struct Connection { name: String, @@ -64,13 +65,14 @@ impl Connection { outcome.await.unwrap_or(Err(GatewayError::Closed)) } - pub(super) fn status(&self) -> ServerStatus { + pub(super) fn status(&self) -> McpServerStatusEntry { let state = self.state.borrow(); - ServerStatus { - name: self.name.clone(), - state: state.status(), - can_sign_in: state.allows_sign_in(self.supports_oauth), - } + let auth = if state.allows_sign_in(self.supports_oauth) { + McpServerAuthCapability::OAuth + } else { + McpServerAuthCapability::Unavailable + }; + McpServerStatusEntry::new(self.name.clone(), state.status()).with_auth_capability(auth) } } @@ -91,13 +93,13 @@ impl ConnectionState { } } - fn status(&self) -> ServerState { + fn status(&self) -> McpServerStatus { match self { - Self::Connecting => ServerState::Connecting, - Self::Authenticating => ServerState::Authenticating, - Self::NeedsOAuth { .. } => ServerState::NeedsOAuth, - Self::Failed { error, .. } => ServerState::Failed { error: error.clone() }, - Self::Connected(server) => ServerState::Connected { tool_count: server.tools().len() }, + Self::Connecting => McpServerStatus::Connecting, + Self::Authenticating => McpServerStatus::Authenticating, + Self::NeedsOAuth { .. } => McpServerStatus::NeedsOAuth, + Self::Failed { error, .. } => McpServerStatus::Failed { error: error.clone() }, + Self::Connected(server) => McpServerStatus::Connected { tool_count: server.tools().len() }, } } } diff --git a/crates/mcp-utils/src/gateway/mcp_gateway.rs b/crates/mcp-utils/src/gateway/mcp_gateway.rs index 6da0bef7c..45302d9f1 100644 --- a/crates/mcp-utils/src/gateway/mcp_gateway.rs +++ b/crates/mcp-utils/src/gateway/mcp_gateway.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use tokio_stream::wrappers::WatchStream; use tokio_util::sync::{CancellationToken, DropGuard}; use tokio_util::task::TaskTracker; +use utils::mcp_status::McpServerStatusEntry; #[derive(Clone)] pub struct McpGateway { @@ -32,22 +33,6 @@ pub struct ConnectedServer { tools: Arc<[Tool]>, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ServerStatus { - pub name: String, - pub state: ServerState, - pub can_sign_in: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ServerState { - Connecting, - Authenticating, - NeedsOAuth, - Failed { error: String }, - Connected { tool_count: usize }, -} - #[derive(Debug, Clone)] pub struct ServerPrompt { pub server: String, @@ -79,7 +64,7 @@ impl McpGateway { self.connections().iter().filter_map(Connection::connected).collect() } - pub fn statuses(&self) -> Vec { + pub fn statuses(&self) -> Vec { self.connections().iter().map(Connection::status).collect() } diff --git a/crates/mcp-utils/src/gateway/mod.rs b/crates/mcp-utils/src/gateway/mod.rs index b5e614628..f4d7f7d04 100644 --- a/crates/mcp-utils/src/gateway/mod.rs +++ b/crates/mcp-utils/src/gateway/mod.rs @@ -3,4 +3,4 @@ mod error; mod mcp_gateway; pub use error::GatewayError; -pub use mcp_gateway::{ConnectedServer, McpGateway, ServerPrompt, ServerSpec, ServerState, ServerStatus}; +pub use mcp_gateway::{ConnectedServer, McpGateway, ServerPrompt, ServerSpec}; diff --git a/crates/mcp-utils/tests/gateway.rs b/crates/mcp-utils/tests/gateway.rs index 7392d1b8f..69e91b741 100644 --- a/crates/mcp-utils/tests/gateway.rs +++ b/crates/mcp-utils/tests/gateway.rs @@ -2,13 +2,14 @@ use futures::{FutureExt, StreamExt}; use mcp_utils::client::{ ClientError, ClientOptions, HeaderMap, HttpAuth, OAuthSettings, ToolCall, ToolCallOptions, Transport, }; -use mcp_utils::gateway::{ConnectedServer, GatewayError, McpGateway, ServerSpec, ServerState}; +use mcp_utils::gateway::{ConnectedServer, GatewayError, McpGateway, ServerSpec}; use mcp_utils::model::{ContentBlock, GetPromptResult, Tool, ToolAnnotations}; use mcp_utils::server::{McpServer, UnixSocket}; use mcp_utils::testing::{FakeMcpServer, FakeMcpState, FakeTool, FakeToolResponse, args, elicitation_host, wait_until}; use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::sync::Weak; +use utils::mcp_status::McpServerStatus; #[tokio::test] async fn servers_are_connecting_until_their_handshake_completes() { @@ -16,7 +17,7 @@ async fn servers_are_connecting_until_their_handshake_completes() { let statuses = gateway.statuses(); assert_eq!(statuses.len(), 1); - assert_eq!(statuses[0].state, ServerState::Connecting); + assert_eq!(statuses[0].status, McpServerStatus::Connecting); gateway.shutdown().await; } @@ -31,9 +32,9 @@ async fn ready_waits_until_every_server_connects_or_fails() { let statuses = gateway.statuses(); assert_eq!(statuses.iter().map(|entry| entry.name.as_str()).collect::>(), ["math", "broken"]); - assert_eq!(statuses[0].state, ServerState::Connected { tool_count: 2 }); - assert!(matches!(&statuses[1].state, ServerState::Failed { error } if error.contains("Failed to spawn"))); - assert!(!statuses[1].can_sign_in); + assert_eq!(statuses[0].status, McpServerStatus::Connected { tool_count: 2 }); + assert!(matches!(&statuses[1].status, McpServerStatus::Failed { error } if error.contains("Failed to spawn"))); + assert!(!statuses[1].can_authenticate()); } #[tokio::test] @@ -49,8 +50,8 @@ async fn unreachable_http_servers_fail_without_offering_oauth() { let gateway = ready(McpGateway::new(vec![unreachable_http("remote")], &options)).await; let status = &gateway.statuses()[0]; - assert!(matches!(status.state, ServerState::Failed { .. })); - assert!(!status.can_sign_in); + assert!(matches!(status.status, McpServerStatus::Failed { .. })); + assert!(!status.can_authenticate()); } #[tokio::test] @@ -64,9 +65,9 @@ async fn failing_servers_do_not_block_the_others() { let statuses = gateway.statuses(); assert_eq!(gateway.servers().iter().map(ConnectedServer::name).collect::>(), ["working"]); - assert!(matches!(statuses[0].state, ServerState::Failed { .. })); - assert!(matches!(statuses[1].state, ServerState::Connected { .. })); - assert!(matches!(statuses[2].state, ServerState::Failed { .. })); + assert!(matches!(statuses[0].status, McpServerStatus::Failed { .. })); + assert!(matches!(statuses[1].status, McpServerStatus::Connected { .. })); + assert!(matches!(statuses[2].status, McpServerStatus::Failed { .. })); } #[tokio::test] @@ -106,7 +107,7 @@ async fn tool_list_changes_refresh_the_tools() { state.clear_tools_and_notify().await; wait_until(&gateway, |gateway| gateway.servers()[0].tools().is_empty()).await; - assert_eq!(gateway.statuses()[0].state, ServerState::Connected { tool_count: 0 }); + assert_eq!(gateway.statuses()[0].status, McpServerStatus::Connected { tool_count: 0 }); } #[tokio::test] @@ -119,7 +120,7 @@ async fn failed_tool_refreshes_keep_the_previous_tools() { fail_a_refresh(&state).await; assert_eq!(names(gateway.servers()[0].tools()), ["add_numbers", "divide_numbers"]); - assert_eq!(gateway.statuses()[0].state, ServerState::Connected { tool_count: 2 }); + assert_eq!(gateway.statuses()[0].status, McpServerStatus::Connected { tool_count: 2 }); } #[tokio::test] @@ -165,7 +166,7 @@ async fn authenticate_needs_a_known_oauth_server() { assert!( matches!(gateway.authenticate("math").await, Err(GatewayError::Client(ClientError::OAuthUnavailable { server })) if server == "math") ); - assert!(!gateway.statuses()[0].can_sign_in); + assert!(!gateway.statuses()[0].can_authenticate()); } #[tokio::test] @@ -260,10 +261,10 @@ async fn a_server_that_disconnects_is_reported_as_failed() { assert_eq!(gateway.servers().len(), 1); server.stop(); - wait_until(&gateway, |gateway| matches!(gateway.statuses()[0].state, ServerState::Failed { .. })).await; + wait_until(&gateway, |gateway| matches!(gateway.statuses()[0].status, McpServerStatus::Failed { .. })).await; assert!(gateway.servers().is_empty()); - assert!(!gateway.statuses()[0].can_sign_in); + assert!(!gateway.statuses()[0].can_authenticate()); } fn gateway(servers: Vec) -> McpGateway { diff --git a/crates/mcp-utils/tests/oauth.rs b/crates/mcp-utils/tests/oauth.rs index 0b06cf2aa..5cf00ae46 100644 --- a/crates/mcp-utils/tests/oauth.rs +++ b/crates/mcp-utils/tests/oauth.rs @@ -2,7 +2,7 @@ use aether_auth::{OAuthClientRegistration, OAuthError}; use mcp_utils::client::{ ClientError, ClientOptions, Elicitation, ElicitationSink, HttpAuth, McpClient, OAuthSettings, Transport, }; -use mcp_utils::gateway::{GatewayError, McpGateway, ServerState}; +use mcp_utils::gateway::{GatewayError, McpGateway}; use mcp_utils::model::{ElicitResult, ElicitationAction, ElicitationCapability, FormElicitationCapability}; use mcp_utils::testing::{ ACCESS_TOKEN, CLIENT_METADATA_URL, FakeAuthServer, FakeMcpServer, OAuthPrompt, client_metadata, elicitation_host, @@ -10,6 +10,7 @@ use mcp_utils::testing::{ }; use tokio::sync::mpsc; use tokio::task::{JoinHandle, yield_now}; +use utils::mcp_status::McpServerStatus; #[tokio::test] async fn connect_reports_the_authorization_challenge() { @@ -142,8 +143,8 @@ async fn gateway_servers_that_demand_oauth_need_it() { gateway.ready().await; let status = &gateway.statuses()[0]; - assert_eq!(status.state, ServerState::NeedsOAuth); - assert!(status.can_sign_in); + assert_eq!(status.status, McpServerStatus::NeedsOAuth); + assert!(status.can_authenticate()); assert!(gateway.servers().is_empty()); } @@ -159,8 +160,8 @@ async fn gateway_servers_that_demand_oauth_fail_without_url_elicitation() { gateway.ready().await; let status = &gateway.statuses()[0]; - assert!(matches!(status.state, ServerState::Failed { .. })); - assert!(!status.can_sign_in); + assert!(matches!(status.status, McpServerStatus::Failed { .. })); + assert!(!status.can_authenticate()); } #[tokio::test] @@ -176,8 +177,8 @@ async fn gateway_authentication_reconnects_the_server_once_authorized() { reqwest::get(callback).await.expect("loopback callback responds"); authentication.await.unwrap().expect("authentication succeeds"); - assert_eq!(gateway.statuses()[0].state, ServerState::Connected { tool_count: 2 }); - assert!(gateway.statuses()[0].can_sign_in); + assert_eq!(gateway.statuses()[0].status, McpServerStatus::Connected { tool_count: 2 }); + assert!(gateway.statuses()[0].can_authenticate()); assert!(gateway.servers()[0].tools().iter().any(|tool| tool.name == "add_numbers")); assert!(matches!(host.recv().await, Some(Elicitation::Complete { server, .. }) if server == "linear")); } @@ -189,7 +190,7 @@ async fn gateway_authentication_reports_a_declined_authorization_and_allows_a_re gateway.ready().await; let authentication = authenticate(&gateway, "linear"); - wait_until(&gateway, |gateway| gateway.statuses()[0].state == ServerState::Authenticating).await; + wait_until(&gateway, |gateway| gateway.statuses()[0].status == McpServerStatus::Authenticating).await; next_prompt(&mut host).await.elicitation.respond(ElicitResult::new(ElicitationAction::Decline)); let declined = authentication.await.unwrap(); @@ -197,8 +198,8 @@ async fn gateway_authentication_reports_a_declined_authorization_and_allows_a_re matches!(declined, Err(GatewayError::Client(ClientError::Auth { server, source: OAuthError::UserCancelled })) if server == "linear") ); let status = gateway.statuses().remove(0); - assert!(matches!(status.state, ServerState::Failed { .. })); - assert!(status.can_sign_in); + assert!(matches!(status.status, McpServerStatus::Failed { .. })); + assert!(status.can_authenticate()); let retry = authenticate(&gateway, "linear"); drop(next_prompt(&mut host).await); assert!(matches!( From 568bc95f0a8e5a13229039fa58314a87dc7bd3f8 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 18:22:09 -0700 Subject: [PATCH 12/26] refactor: share to_meta / from_meta through utils::meta Move the generic metadata helpers from acp-utils into utils, since ACP's Meta is a plain JSON map, and build ArtifactReviewElicitationMeta on them instead of its own copies. - ArtifactReviewElicitationMeta::to_json becomes an infallible to_meta; the struct always serializes to an object, so the error path in mcp-servers' review tool was dead. - ArtifactReviewElicitationMeta::parse becomes from_meta, matching RemoteServerInfo::from_meta. --- crates/acp-utils/src/config_meta.rs | 2 +- crates/acp-utils/src/lib.rs | 1 - crates/acp-utils/src/meta.rs | 26 ------------------- crates/acp-utils/src/notifications.rs | 2 +- .../src/review/tools/review_artifact/mod.rs | 7 +---- .../tests/integration/review_mcp.rs | 4 +-- crates/utils/src/artifact_review.rs | 17 +++++------- crates/utils/src/lib.rs | 1 + crates/utils/src/meta.rs | 24 +++++++++++++++++ crates/wisp/src/app/acp_reducer.rs | 2 +- crates/wisp/tests/tui/artifact_review.rs | 6 ++--- 11 files changed, 39 insertions(+), 53 deletions(-) delete mode 100644 crates/acp-utils/src/meta.rs create mode 100644 crates/utils/src/meta.rs diff --git a/crates/acp-utils/src/config_meta.rs b/crates/acp-utils/src/config_meta.rs index c84b89ec0..f55239d6e 100644 --- a/crates/acp-utils/src/config_meta.rs +++ b/crates/acp-utils/src/config_meta.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use utils::ReasoningEffort; -use crate::meta::{from_meta, to_meta}; +use utils::meta::{from_meta, to_meta}; type Meta = serde_json::Map; diff --git a/crates/acp-utils/src/lib.rs b/crates/acp-utils/src/lib.rs index 0ba611e5c..6f7b41a4e 100644 --- a/crates/acp-utils/src/lib.rs +++ b/crates/acp-utils/src/lib.rs @@ -7,7 +7,6 @@ pub mod content; pub mod conversation; #[cfg(not(target_family = "wasm"))] pub mod elicitation; -pub mod meta; pub mod notifications; #[cfg(feature = "websocket")] diff --git a/crates/acp-utils/src/meta.rs b/crates/acp-utils/src/meta.rs deleted file mode 100644 index 9ac761bb6..000000000 --- a/crates/acp-utils/src/meta.rs +++ /dev/null @@ -1,26 +0,0 @@ -use agent_client_protocol::schema::v2::Meta; -use serde::{Serialize, de::DeserializeOwned}; - -/// Serialize metadata, optionally under an extension namespace. Empty objects are omitted. -pub fn to_meta(value: &T, namespace: Option<&str>) -> Option { - let serde_json::Value::Object(map) = serde_json::to_value(value).expect("metadata should serialize") else { - panic!("metadata must serialize as an object"); - }; - if map.is_empty() { - return None; - } - Some(match namespace { - Some(namespace) => Meta::from_iter([(namespace.to_owned(), serde_json::Value::Object(map))]), - None => map, - }) -} - -/// Decode metadata, treating absent or invalid values as the type's default. -pub fn from_meta(meta: Option<&Meta>, namespace: Option<&str>) -> T { - meta.and_then(|meta| match namespace { - Some(namespace) => meta.get(namespace).cloned(), - None => Some(serde_json::Value::Object(meta.clone())), - }) - .and_then(|value| serde_json::from_value(value).ok()) - .unwrap_or_default() -} diff --git a/crates/acp-utils/src/notifications.rs b/crates/acp-utils/src/notifications.rs index 2b76cf968..3f3ecf536 100644 --- a/crates/acp-utils/src/notifications.rs +++ b/crates/acp-utils/src/notifications.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; pub use utils::display_meta::{ToolDisplayMeta, ToolResultMeta}; pub use utils::mcp_status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry}; -use crate::meta::{from_meta, to_meta}; +use utils::meta::{from_meta, to_meta}; pub const AETHER_META_NAMESPACE: &str = "contextbridge/aether"; diff --git a/crates/mcp-servers/src/review/tools/review_artifact/mod.rs b/crates/mcp-servers/src/review/tools/review_artifact/mod.rs index 6b2bff283..163ab8119 100644 --- a/crates/mcp-servers/src/review/tools/review_artifact/mod.rs +++ b/crates/mcp-servers/src/review/tools/review_artifact/mod.rs @@ -264,11 +264,6 @@ fn build_elicitation_form( artifact: &ArtifactReviewElicitationMeta, subject: &str, ) -> Result { - let meta = artifact - .to_json() - .map(RequestMetaObject::from) - .map_err(|error| format!("Failed to serialize artifact review metadata: {error}"))?; - let requested_schema = ElicitationSchema::builder() .required_enum_schema( "decision", @@ -279,7 +274,7 @@ fn build_elicitation_form( .map_err(|error| format!("Failed to build the review form schema: {error}"))?; Ok(ElicitRequestParams::FormElicitationParams { - meta: Some(meta), + meta: Some(RequestMetaObject::from(artifact.to_meta())), message: format!("Review {subject} and approve or submit feedback."), requested_schema, }) diff --git a/crates/mcp-servers/tests/integration/review_mcp.rs b/crates/mcp-servers/tests/integration/review_mcp.rs index 09246a276..d46acd9cb 100644 --- a/crates/mcp-servers/tests/integration/review_mcp.rs +++ b/crates/mcp-servers/tests/integration/review_mcp.rs @@ -52,7 +52,7 @@ async fn reviews_inline_markdown_without_creating_a_file() -> TestResult { let ElicitRequestParams::FormElicitationParams { meta: Some(meta), .. } = &captured[0].request else { return Err(test_error("expected artifact metadata in the review UI request").into()); }; - let meta = ArtifactReviewElicitationMeta::parse(Some(&meta.0)) + let meta = ArtifactReviewElicitationMeta::from_meta(Some(&meta.0)) .ok_or_else(|| test_error("invalid artifact metadata in the review UI request"))?; assert_eq!(meta.title, "Database choice"); assert_eq!(meta.markdown, "# Pick one\n\n- A\n- B"); @@ -395,7 +395,7 @@ impl FormReview { .as_ref() .or_else(|| request.extensions.get::()) .ok_or_else(|| test_error("expected artifact metadata"))?; - let meta = ArtifactReviewElicitationMeta::parse(Some(&meta.0)) + let meta = ArtifactReviewElicitationMeta::from_meta(Some(&meta.0)) .ok_or_else(|| test_error("invalid artifact metadata"))?; Ok(Self { meta, message: message.clone(), schema: serde_json::to_value(requested_schema)? }) } diff --git a/crates/utils/src/artifact_review.rs b/crates/utils/src/artifact_review.rs index 919954eb9..e8e6e655b 100644 --- a/crates/utils/src/artifact_review.rs +++ b/crates/utils/src/artifact_review.rs @@ -1,3 +1,4 @@ +use crate::meta::{from_meta, to_meta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -60,18 +61,12 @@ impl ArtifactReviewElicitationMeta { Self { ui: ARTIFACT_REVIEW_UI_KIND.to_string(), path, title: title.into(), markdown: markdown.into() } } - pub fn to_json(&self) -> Result, serde_json::Error> { - serde_json::to_value(self).and_then(|value| match value { - Value::Object(map) => Ok(map), - _ => Err(serde_json::Error::io(std::io::Error::other( - "artifact review metadata did not serialize to an object", - ))), - }) + pub fn to_meta(&self) -> Map { + to_meta(self, None).unwrap_or_default() } - pub fn parse(meta: Option<&Map>) -> Option { - let parsed = serde_json::from_value::(Value::Object(meta?.clone())).ok()?; - (parsed.ui == ARTIFACT_REVIEW_UI_KIND).then_some(parsed) + pub fn from_meta(meta: Option<&Map>) -> Option { + from_meta::>(meta, None).filter(|parsed| parsed.ui == ARTIFACT_REVIEW_UI_KIND) } } @@ -118,7 +113,7 @@ mod tests { #[test] fn metadata_round_trips() { let meta = ArtifactReviewElicitationMeta::new(Some(PathBuf::from("docs/question.md")), "Review", "# Question"); - let parsed = ArtifactReviewElicitationMeta::parse(Some(&meta.to_json().expect("serialize"))).expect("parse"); + let parsed = ArtifactReviewElicitationMeta::from_meta(Some(&meta.to_meta())).expect("parse"); assert_eq!(parsed, meta); assert_eq!(parsed.ui, "artifactReview"); assert_eq!(parsed.path.as_deref(), Some(Path::new("docs/question.md"))); diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index c63942ca7..764f33982 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -5,6 +5,7 @@ pub mod display_meta; #[cfg(not(target_family = "wasm"))] pub mod git; pub mod mcp_status; +pub mod meta; pub mod name_pattern; pub mod panic; pub mod path; diff --git a/crates/utils/src/meta.rs b/crates/utils/src/meta.rs new file mode 100644 index 000000000..11763504d --- /dev/null +++ b/crates/utils/src/meta.rs @@ -0,0 +1,24 @@ +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Map, Value}; + +pub fn to_meta(value: &T, namespace: Option<&str>) -> Option> { + let Value::Object(map) = serde_json::to_value(value).expect("metadata should serialize") else { + panic!("metadata must serialize as an object"); + }; + if map.is_empty() { + return None; + } + Some(match namespace { + Some(namespace) => Map::from_iter([(namespace.to_owned(), Value::Object(map))]), + None => map, + }) +} + +pub fn from_meta(meta: Option<&Map>, namespace: Option<&str>) -> T { + meta.and_then(|meta| match namespace { + Some(namespace) => meta.get(namespace).cloned(), + None => Some(Value::Object(meta.clone())), + }) + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default() +} diff --git a/crates/wisp/src/app/acp_reducer.rs b/crates/wisp/src/app/acp_reducer.rs index cf0aa2eb8..ffa0c090f 100644 --- a/crates/wisp/src/app/acp_reducer.rs +++ b/crates/wisp/src/app/acp_reducer.rs @@ -194,5 +194,5 @@ pub(super) fn artifact_review_meta(params: &CreateElicitationRequest) -> Option< if !matches!(params.mode, ElicitationMode::Form(_)) { return None; } - ArtifactReviewElicitationMeta::parse(params.meta.as_ref()) + ArtifactReviewElicitationMeta::from_meta(params.meta.as_ref()) } diff --git a/crates/wisp/tests/tui/artifact_review.rs b/crates/wisp/tests/tui/artifact_review.rs index 3dedc75d0..23c00c55d 100644 --- a/crates/wisp/tests/tui/artifact_review.rs +++ b/crates/wisp/tests/tui/artifact_review.rs @@ -160,8 +160,7 @@ fn approval_closes_the_route_and_notifies_the_user() { let mut app = make_app(); let meta = ArtifactReviewElicitationMeta::new(Some(PathBuf::from("/tmp/plan.md")), "Review /tmp/plan.md", "# Plan") - .to_json() - .unwrap(); + .to_meta(); with_elicitation(&mut app, form_elicitation("review", "Review artifact", ElicitationSchema::new()).meta(meta)) .await; app.key(KeyCode::Char('r').into()); @@ -182,8 +181,7 @@ fn double_ctrl_c_exits_over_artifact_review() { "Review /tmp/plan.md", "# Plan\nbody", ) - .to_json() - .unwrap(); + .to_meta(); with_elicitation(&mut app, form_elicitation("review", "Review artifact", ElicitationSchema::new()).meta(meta)) .await; assert!(app.app().full_screen_active()); From 0798d8d17f6f33327706256571b7ff19630dae82 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 18:53:34 -0700 Subject: [PATCH 13/26] refactor: derive Default for AgentSpec AgentSpec and AgentSpecExposure now derive Default, so test specs and AgentSpec::bare set only the fields they care about and fill the rest with ..AgentSpec::default(). The derived exposure default replaces AgentSpecExposure::none(). The production literal in agent_catalog keeps every field explicit so a new AgentSpec field cannot silently fall back to a default there. --- crates/aether-cli/src/acp/session/model.rs | 8 +----- crates/aether-cli/src/acp/testing.rs | 14 +++++----- crates/aether-core/src/agent_spec.rs | 22 +++------------- crates/aether-core/src/core/agent_builder.rs | 26 ++----------------- crates/aether-core/src/core/agent_registry.rs | 17 +++++++----- crates/aether-project/src/testing.rs | 10 +------ 6 files changed, 26 insertions(+), 71 deletions(-) diff --git a/crates/aether-cli/src/acp/session/model.rs b/crates/aether-cli/src/acp/session/model.rs index f6f15f817..a62d310f1 100644 --- a/crates/aether-cli/src/acp/session/model.rs +++ b/crates/aether-cli/src/acp/session/model.rs @@ -245,7 +245,6 @@ mod tests { use super::*; use aether_auth::FakeOAuthCredentialStore; use aether_core::agent_spec::AgentSpecExposure; - use aether_core::mcp::{McpConfig, ToolFilter}; use agent_client_protocol::schema::v2::{SessionConfigKind, SessionConfigSelectOption, SessionConfigSelectOptions}; use llm::catalog::{AnthropicModel, BedrockFoundationModel, BedrockModel, DeepSeekModel, GeminiModel}; @@ -265,13 +264,8 @@ mod tests { description: name.to_lowercase(), model: model.to_string(), reasoning_effort: effort, - model_settings: llm::ModelSettings::default(), - context_window: None, - prompts: vec![], - provider_connections: llm::ProviderConnectionOverrides::default(), - mcp: McpConfig::default(), exposure: AgentSpecExposure::both(), - tools: ToolFilter::default(), + ..AgentSpec::default() } } diff --git a/crates/aether-cli/src/acp/testing.rs b/crates/aether-cli/src/acp/testing.rs index fb1ddc8f8..a939e28ff 100644 --- a/crates/aether-cli/src/acp/testing.rs +++ b/crates/aether-cli/src/acp/testing.rs @@ -995,12 +995,14 @@ fn text_only_model() -> String { } fn fake_agent_spec(name: &str) -> AgentSpec { - let model: llm::catalog::LlmModel = "anthropic:claude-sonnet-4-5".parse().expect("test model parses"); - let mut spec = AgentSpec::bare(&model, None, vec![Prompt::text(&format!("{name} system prompt"))]); - spec.name = name.to_string(); - spec.description = format!("{name} test agent"); - spec.exposure = AgentSpecExposure::user_only(); - spec + AgentSpec { + name: name.to_string(), + description: format!("{name} test agent"), + model: "anthropic:claude-sonnet-4-5".to_string(), + prompts: vec![Prompt::text(&format!("{name} system prompt"))], + exposure: AgentSpecExposure::user_only(), + ..AgentSpec::default() + } } fn conversation_texts(context: &Context) -> Vec { diff --git a/crates/aether-core/src/agent_spec.rs b/crates/aether-core/src/agent_spec.rs index 46416c91b..844047e0d 100644 --- a/crates/aether-core/src/agent_spec.rs +++ b/crates/aether-core/src/agent_spec.rs @@ -11,7 +11,7 @@ use llm::{LlmModel, ModelSettings, ProviderConnectionOverrides, ReasoningEffort} /// /// This type is produced by validating and resolving authored agent configuration. /// All validation happens before constructing these runtime types. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct AgentSpec { /// The canonical lookup key for this agent. pub name: String, @@ -50,19 +50,14 @@ impl AgentSpec { description: "Default agent".to_string(), model: model.to_string(), reasoning_effort, - model_settings: ModelSettings::default(), - context_window: None, prompts, - provider_connections: ProviderConnectionOverrides::default(), - mcp: McpConfig::default(), - exposure: AgentSpecExposure::none(), - tools: ToolFilter::default(), + ..Self::default() } } } /// Defines how an agent can be invoked. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct AgentSpecExposure { /// Whether this agent can be invoked by users (e.g., as an ACP mode). pub user_invocable: bool, @@ -71,15 +66,6 @@ pub struct AgentSpecExposure { } impl AgentSpecExposure { - /// Create an exposure that is neither user nor agent invocable. - /// - /// Used internally for synthesized default specs (e.g., no-mode sessions). - /// Not intended for authored agent definitions — all authored agents must - /// have at least one invocation surface. - pub fn none() -> Self { - Self { user_invocable: false, agent_invocable: false } - } - /// Create an exposure that is only user invocable. pub fn user_only() -> Self { Self { user_invocable: true, agent_invocable: false } @@ -113,6 +99,6 @@ mod tests { assert!(spec.reasoning_effort.is_none()); assert_eq!(spec.prompts.len(), 1); assert!(spec.mcp.is_empty()); - assert_eq!(spec.exposure, AgentSpecExposure::none()); + assert_eq!(spec.exposure, AgentSpecExposure::default()); } } diff --git a/crates/aether-core/src/core/agent_builder.rs b/crates/aether-core/src/core/agent_builder.rs index 542917a24..e82cbcd63 100644 --- a/crates/aether-core/src/core/agent_builder.rs +++ b/crates/aether-core/src/core/agent_builder.rs @@ -281,10 +281,7 @@ impl AgentBuilder { #[cfg(test)] mod tests { use super::*; - use crate::agent_spec::AgentSpecExposure; use crate::events::TurnEvent; - use crate::mcp::{McpConfig, ToolFilter}; - use llm::ProviderConnectionOverrides; use llm::testing::FakeLlmProvider; #[tokio::test] @@ -328,17 +325,10 @@ mod tests { async fn from_spec_applies_context_window_and_model_settings() { let settings = ModelSettings { temperature: Some(0.0), max_tokens: Some(128), ..Default::default() }; let spec = AgentSpec { - name: "alloy".to_string(), - description: "alloy".to_string(), model: "ollama:llama3.2,llamacpp:local".to_string(), - reasoning_effort: None, model_settings: settings.clone(), context_window: Some(200_000), - prompts: vec![], - provider_connections: ProviderConnectionOverrides::default(), - mcp: McpConfig::default(), - exposure: AgentSpecExposure::both(), - tools: ToolFilter::default(), + ..AgentSpec::default() }; let dependencies = AgentDeps::default().with_session_affinity_key("conversation-123"); @@ -351,19 +341,7 @@ mod tests { #[tokio::test] async fn from_spec_accepts_alloy_model_specs() { - let spec = AgentSpec { - name: "alloy".to_string(), - description: "alloy".to_string(), - model: "ollama:llama3.2,llamacpp:local".to_string(), - reasoning_effort: None, - model_settings: ModelSettings::default(), - context_window: None, - prompts: vec![], - provider_connections: ProviderConnectionOverrides::default(), - mcp: McpConfig::default(), - exposure: AgentSpecExposure::both(), - tools: ToolFilter::default(), - }; + let spec = AgentSpec { model: "ollama:llama3.2,llamacpp:local".to_string(), ..AgentSpec::default() }; let builder = AgentBuilder::from_spec(&spec, &AgentDeps::default()).await; assert!(builder.is_ok()); diff --git a/crates/aether-core/src/core/agent_registry.rs b/crates/aether-core/src/core/agent_registry.rs index 15e895f98..b3b52df5a 100644 --- a/crates/aether-core/src/core/agent_registry.rs +++ b/crates/aether-core/src/core/agent_registry.rs @@ -50,13 +50,16 @@ mod tests { #[test] fn delegation_resolution_distinguishes_missing_and_unexposed_agents() { - let model = "anthropic:claude-sonnet-4-5".parse().unwrap(); - let mut delegate = AgentSpec::bare(&model, None, Vec::new()); - delegate.name = "delegate".to_string(); - delegate.exposure = AgentSpecExposure::agent_only(); - let mut user_only = AgentSpec::bare(&model, None, Vec::new()); - user_only.name = "user-only".to_string(); - user_only.exposure = AgentSpecExposure::user_only(); + let delegate = AgentSpec { + name: "delegate".to_string(), + exposure: AgentSpecExposure::agent_only(), + ..AgentSpec::default() + }; + let user_only = AgentSpec { + name: "user-only".to_string(), + exposure: AgentSpecExposure::user_only(), + ..AgentSpec::default() + }; let registry = AgentRegistry::new(vec![delegate, user_only]); assert_eq!(registry.resolve_agent_invocable("delegate").unwrap().name, "delegate"); diff --git a/crates/aether-project/src/testing.rs b/crates/aether-project/src/testing.rs index 02794b537..b700a0e3a 100644 --- a/crates/aether-project/src/testing.rs +++ b/crates/aether-project/src/testing.rs @@ -10,8 +10,6 @@ use serde_json::{Value, json}; use crate::prompt_file::{PromptFile, PromptTriggers}; use crate::{AgentConfig, PromptSource}; use aether_core::agent_spec::{AgentSpec, AgentSpecExposure}; -use aether_core::mcp::{McpConfig, ToolFilter}; -use llm::{ModelSettings, ProviderConnectionOverrides}; pub use utils::testing::TestWorkspace; /// The model id every shared agent fixture resolves to. @@ -94,14 +92,8 @@ pub fn fake_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec { name: name.to_string(), description: format!("{name} agent"), model: DEFAULT_MODEL.to_string(), - reasoning_effort: None, - model_settings: ModelSettings::default(), - context_window: None, - prompts: vec![], - provider_connections: ProviderConnectionOverrides::default(), - mcp: McpConfig::default(), exposure, - tools: ToolFilter::default(), + ..AgentSpec::default() } } From 1223044c0a6df647cb236de86ebc7e3f15ed9e91 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 19:07:19 -0700 Subject: [PATCH 14/26] refactor: add AgentEvent tool_call / tool_result / tool_error constructors Tests across evals, sessions, telemetry, aether-core and aether-cli hand-built ToolEvent::Call / Result / Error literals around ToolCallRequest / ToolCallResult / ToolCallError. Add constructors next to AgentEvent::text and turn_ended and use them instead. aether-cli's output and headless tests also shared identical progress, retry, LLM-call and usage fixtures; they now live once in a test-only output::test_events module. --- crates/aether-cli/src/acp/testing.rs | 28 +--- crates/aether-cli/src/headless/run.rs | 92 ++----------- crates/aether-cli/src/output.rs | 125 ++++++++---------- crates/aether-core/src/events/agent_event.rs | 34 ++++- .../src/testing/agent_event_builder.rs | 28 +--- crates/aether-evals/src/agents/fake_agent.rs | 14 +- crates/aether-evals/src/agents/transcript.rs | 16 +-- crates/aether-evals/src/assertions.rs | 20 +-- crates/aether-evals/src/judge.rs | 28 +--- crates/aether-sessions/src/testing.rs | 25 +--- .../aether-telemetry/tests/observer_tests.rs | 15 +-- .../tests/otlp_collector_tests.rs | 12 +- 12 files changed, 129 insertions(+), 308 deletions(-) diff --git a/crates/aether-cli/src/acp/testing.rs b/crates/aether-cli/src/acp/testing.rs index a939e28ff..0cb5ee58e 100644 --- a/crates/aether-cli/src/acp/testing.rs +++ b/crates/aether-cli/src/acp/testing.rs @@ -18,7 +18,7 @@ use acp_utils::testing::{TestPeer, initialize_request}; use aether_auth::OAuthCredentialStorage; use aether_core::agent_spec::{AgentSpec, AgentSpecExposure}; use aether_core::core::{AgentBuilder, AgentHandle, Prompt}; -use aether_core::events::{AgentEvent, Command, MessageEvent, ToolEvent, TurnEvent, TurnOutcome, UserCommand}; +use aether_core::events::{AgentEvent, Command, MessageEvent, TurnEvent, TurnOutcome, UserCommand}; use aether_core::mcp::{McpBuilder, McpCatalog, mcp}; use aether_core::testing::ready_catalog; use aether_project::AgentCatalog; @@ -31,7 +31,7 @@ use agent_client_protocol::schema::v2::{ use agent_client_protocol::{Agent, Channel, Client, ConnectionTo, on_receive_notification}; use llm::testing::FakeLlmProvider; use llm::{ChatMessage, Context, LlmModel, LlmResponse, SessionUsageEvent, StreamingModelProvider}; -use llm::{MessageId, ProviderConnectionOverrides, ToolCallRequest, ToolCallResult}; +use llm::{MessageId, ProviderConnectionOverrides}; use mcp_utils::client::{Elicitation, ElicitationSink}; use mcp_utils::gateway::ServerSpec; use mcp_utils::model::{CallToolResult, CreateTaskResult, DetailedTask, ElicitResult, Task, TaskPayload, TaskStatus}; @@ -524,31 +524,11 @@ impl AcpTestHarness { } pub fn append_stored_tool_call(&self, session_id: &str, id: &str, name: &str, arguments: &str) { - self.append_stored_event( - session_id, - &SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { - id: id.to_string(), - name: name.to_string(), - arguments: arguments.to_string(), - }, - })), - ); + self.append_stored_event(session_id, &SessionEvent::Agent(AgentEvent::tool_call(id, name, arguments))); } pub fn append_stored_tool_result(&self, session_id: &str, id: &str, name: &str, result: &str) { - self.append_stored_event( - session_id, - &SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Result { - result: ToolCallResult { - id: id.to_string(), - name: name.to_string(), - arguments: "{}".to_string(), - result: result.to_string(), - }, - result_meta: None, - })), - ); + self.append_stored_event(session_id, &SessionEvent::Agent(AgentEvent::tool_result(id, name, "{}", result))); } async fn insert_switching_session( diff --git a/crates/aether-cli/src/headless/run.rs b/crates/aether-cli/src/headless/run.rs index 530d9e323..7dd592a99 100644 --- a/crates/aether-cli/src/headless/run.rs +++ b/crates/aether-cli/src/headless/run.rs @@ -138,7 +138,7 @@ mod tests { use aether_core::events::StreamState; use super::*; - use llm::ContextUsage; + use crate::output::test_events::{llm_call_started, retry_scheduled, tool_progress, usage_update}; #[test] fn event_kind_none_for_non_output_fragments() { @@ -161,7 +161,7 @@ mod tests { #[test] fn should_emit_empty_filter_rejects_non_output_events() { - assert!(should_emit(&tool_call_msg(), &[])); + assert!(should_emit(&AgentEvent::tool_call("tc1", "bash", "{}"), &[])); assert!(should_emit(&AgentEvent::turn_ended(TurnOutcome::failed("e")), &[])); assert!(should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), &[])); assert!(!should_emit(&AgentEvent::text("id", "x", StreamState::Partial), &[])); @@ -174,16 +174,16 @@ mod tests { #[test] fn should_emit_single_type_whitelist() { let filter = &[CliEventKind::ToolCall]; - assert!(should_emit(&tool_call_msg(), filter)); - assert!(!should_emit(&tool_result_msg(), filter)); + assert!(should_emit(&AgentEvent::tool_call("tc1", "bash", "{}"), filter)); + assert!(!should_emit(&AgentEvent::tool_result("tc1", "bash", "{}", "ok"), filter)); assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter)); } #[test] fn should_emit_multi_type_whitelist() { let filter = &[CliEventKind::ToolCall, CliEventKind::ToolResult]; - assert!(should_emit(&tool_call_msg(), filter)); - assert!(should_emit(&tool_result_msg(), filter)); + assert!(should_emit(&AgentEvent::tool_call("tc1", "bash", "{}"), filter)); + assert!(should_emit(&AgentEvent::tool_result("tc1", "bash", "{}", "ok"), filter)); assert!(!should_emit(&AgentEvent::turn_ended(TurnOutcome::Completed), filter)); } @@ -201,19 +201,9 @@ mod tests { let samples = vec![ (AgentEvent::text("id", "x", StreamState::Complete), CliEventKind::Text), (AgentEvent::thought("id", "x", StreamState::Complete), CliEventKind::Thought), - (tool_call_msg(), CliEventKind::ToolCall), - (tool_result_msg(), CliEventKind::ToolResult), - ( - AgentEvent::Tool(ToolEvent::Error { - error: llm::ToolCallError { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: None, - error: "boom".to_string(), - }, - }), - CliEventKind::ToolError, - ), + (AgentEvent::tool_call("tc1", "bash", "{}"), CliEventKind::ToolCall), + (AgentEvent::tool_result("tc1", "bash", "{}", "ok"), CliEventKind::ToolResult), + (AgentEvent::tool_error("tc1", "bash", "{}", "boom"), CliEventKind::ToolError), ( AgentEvent::Turn(TurnEvent::AutoContinue { attempt: 1, @@ -305,68 +295,4 @@ mod tests { let code = stream_output(&mut rx, OutputFormat::Text, &[]).await; assert_eq!(code, ExitCode::FAILURE); } - - fn tool_call_msg() -> AgentEvent { - AgentEvent::Tool(ToolEvent::Call { - request: llm::ToolCallRequest { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: "{}".to_string(), - }, - }) - } - - fn tool_result_msg() -> AgentEvent { - AgentEvent::Tool(ToolEvent::Result { - result: llm::ToolCallResult { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: "{}".to_string(), - result: "ok".to_string(), - }, - result_meta: None, - }) - } - - fn tool_progress(progress: f64, total: Option, message: Option<&str>) -> AgentEvent { - AgentEvent::Tool(ToolEvent::Progress { - request: llm::ToolCallRequest { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: "{}".to_string(), - }, - progress, - total, - message: message.map(str::to_string), - }) - } - - fn retry_scheduled(attempt: u32, delay_ms: u64) -> AgentEvent { - AgentEvent::Turn(TurnEvent::RetryScheduled { - purpose: llm::LlmCallPurpose::Chat, - attempt, - max_attempts: 3, - delay_ms, - }) - } - - fn llm_call_started(attempt: u32) -> AgentEvent { - AgentEvent::Turn(TurnEvent::LlmCallStarted { - purpose: llm::LlmCallPurpose::Chat, - model: llm::ModelIdentity::default(), - display_name: "test".to_string(), - attempt, - max_attempts: 3, - }) - } - - fn usage_update() -> AgentEvent { - AgentEvent::Context(ContextEvent::UsageUpdated { - usage: ContextUsage { - input_tokens: 100_000.into(), - context_limit: Some(200_000.into()), - usage_ratio: Some(0.5), - }, - }) - } } diff --git a/crates/aether-cli/src/output.rs b/crates/aether-cli/src/output.rs index e65cb41df..cbcb99018 100644 --- a/crates/aether-cli/src/output.rs +++ b/crates/aether-cli/src/output.rs @@ -161,8 +161,56 @@ fn format_session_usage(usage: &llm::SessionUsageEvent) -> String { ) } +#[cfg(test)] +pub(crate) mod test_events { + use aether_core::events::{AgentEvent, ContextEvent, ToolEvent, TurnEvent}; + + pub fn tool_progress(progress: f64, total: Option, message: Option<&str>) -> AgentEvent { + AgentEvent::Tool(ToolEvent::Progress { + request: llm::ToolCallRequest { + id: "tc1".to_string(), + name: "bash".to_string(), + arguments: "{}".to_string(), + }, + progress, + total, + message: message.map(str::to_string), + }) + } + + pub fn llm_call_started(attempt: u32) -> AgentEvent { + AgentEvent::Turn(TurnEvent::LlmCallStarted { + purpose: llm::LlmCallPurpose::Chat, + model: llm::ModelIdentity::default(), + display_name: "test".to_string(), + attempt, + max_attempts: 3, + }) + } + + pub fn retry_scheduled(attempt: u32, delay_ms: u64) -> AgentEvent { + AgentEvent::Turn(TurnEvent::RetryScheduled { + purpose: llm::LlmCallPurpose::Chat, + attempt, + max_attempts: 3, + delay_ms, + }) + } + + pub fn usage_update() -> AgentEvent { + AgentEvent::Context(ContextEvent::UsageUpdated { + usage: llm::ContextUsage { + input_tokens: 100_000.into(), + context_limit: Some(200_000.into()), + usage_ratio: Some(0.5), + }, + }) + } +} + #[cfg(test)] mod tests { + use super::test_events::{llm_call_started, retry_scheduled, tool_progress, usage_update}; use super::*; use aether_core::events::StreamState; @@ -194,13 +242,7 @@ mod tests { #[test] fn format_text_formats_tool_call() { - let message = AgentEvent::Tool(ToolEvent::Call { - request: llm::ToolCallRequest { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: r#"{"cmd":"ls"}"#.to_string(), - }, - }); + let message = AgentEvent::tool_call("tc1", "bash", r#"{"cmd":"ls"}"#); assert_eq!(format_text(&message), Some(r#"Tool call: bash({"cmd":"ls"})"#.to_string())); } @@ -214,19 +256,15 @@ mod tests { #[test] fn format_text_formats_tool_result() { - assert_eq!(format_text(&tool_result()), Some("Tool result [bash]: ok".to_string())); + assert_eq!( + format_text(&AgentEvent::tool_result("tc1", "bash", "{}", "ok")), + Some("Tool result [bash]: ok".to_string()) + ); } #[test] fn format_text_formats_tool_error() { - let message = AgentEvent::Tool(ToolEvent::Error { - error: llm::ToolCallError { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: None, - error: "not found".to_string(), - }, - }); + let message = AgentEvent::tool_error("tc1", "bash", "{}", "not found"); assert_eq!(format_text(&message), Some("Tool error [bash]: not found".to_string())); } @@ -242,15 +280,8 @@ mod tests { #[test] fn format_text_formats_retry_events() { - let started = AgentEvent::Turn(TurnEvent::LlmCallStarted { - purpose: llm::LlmCallPurpose::Chat, - model: llm::ModelIdentity::default(), - display_name: "test".to_string(), - attempt: 0, - max_attempts: 3, - }); - assert_eq!(format_text(&started), None); - assert_eq!(format_text(&retry_scheduled()), Some("Retrying (1/3) in 10ms".to_string())); + assert_eq!(format_text(&llm_call_started(0)), None); + assert_eq!(format_text(&retry_scheduled(1, 10)), Some("Retrying (1/3) in 10ms".to_string())); let retrying = AgentEvent::Turn(TurnEvent::LlmCallEnded { purpose: llm::LlmCallPurpose::Chat, outcome: LlmCallOutcome::failed("overloaded", true), @@ -303,48 +334,4 @@ mod tests { assert_eq!(format_text(&usage_update()), Some("Context: 100000 / 200000 tokens (50.0%)".to_string())); assert_eq!(format_text(&AgentEvent::Context(ContextEvent::Cleared)), Some("Context cleared".to_string())); } - - fn tool_result() -> AgentEvent { - AgentEvent::Tool(ToolEvent::Result { - result: llm::ToolCallResult { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: "{}".to_string(), - result: "ok".to_string(), - }, - result_meta: None, - }) - } - - fn tool_progress(progress: f64, total: Option, message: Option<&str>) -> AgentEvent { - AgentEvent::Tool(ToolEvent::Progress { - request: llm::ToolCallRequest { - id: "tc1".to_string(), - name: "bash".to_string(), - arguments: "{}".to_string(), - }, - progress, - total, - message: message.map(str::to_string), - }) - } - - fn retry_scheduled() -> AgentEvent { - AgentEvent::Turn(TurnEvent::RetryScheduled { - purpose: llm::LlmCallPurpose::Chat, - attempt: 1, - max_attempts: 3, - delay_ms: 10, - }) - } - - fn usage_update() -> AgentEvent { - AgentEvent::Context(ContextEvent::UsageUpdated { - usage: llm::ContextUsage { - input_tokens: 100_000.into(), - context_limit: Some(200_000.into()), - usage_ratio: Some(0.5), - }, - }) - } } diff --git a/crates/aether-core/src/events/agent_event.rs b/crates/aether-core/src/events/agent_event.rs index 1176757f0..1ba553a64 100644 --- a/crates/aether-core/src/events/agent_event.rs +++ b/crates/aether-core/src/events/agent_event.rs @@ -1,3 +1,4 @@ +use llm::{ToolCallError, ToolCallRequest, ToolCallResult}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -37,6 +38,35 @@ impl AgentEvent { Self::Turn(TurnEvent::Ended { outcome }) } + pub fn tool_call(id: &str, name: &str, arguments: &str) -> Self { + Self::Tool(ToolEvent::Call { + request: ToolCallRequest { id: id.into(), name: name.into(), arguments: arguments.into() }, + }) + } + + pub fn tool_result(id: &str, name: &str, arguments: &str, result: &str) -> Self { + Self::Tool(ToolEvent::Result { + result: ToolCallResult { + id: id.into(), + name: name.into(), + arguments: arguments.into(), + result: result.into(), + }, + result_meta: None, + }) + } + + pub fn tool_error(id: &str, name: &str, arguments: &str, error: &str) -> Self { + Self::Tool(ToolEvent::Error { + error: ToolCallError { + id: id.into(), + name: name.into(), + arguments: Some(arguments.into()), + error: error.into(), + }, + }) + } + /// Human-readable text content of this event, if any. pub fn content(&self) -> Option { match self { @@ -218,9 +248,7 @@ mod tests { AgentEvent::text("m", "partial", StreamState::Complete), AgentEvent::thought("m", "partial", StreamState::Complete), AgentEvent::Tool(ToolEvent::InputStarted { id: "call".into(), name: "read".into() }), - AgentEvent::Tool(ToolEvent::Call { - request: llm::ToolCallRequest { id: "call".into(), name: "read".into(), arguments: "{}".into() }, - }), + AgentEvent::tool_call("call", "read", "{}"), AgentEvent::turn_ended(TurnOutcome::Completed), ]; assert!(deltas.iter().all(AgentEvent::is_stream_delta)); diff --git a/crates/aether-core/src/testing/agent_event_builder.rs b/crates/aether-core/src/testing/agent_event_builder.rs index f454241a5..8249efaea 100644 --- a/crates/aether-core/src/testing/agent_event_builder.rs +++ b/crates/aether-core/src/testing/agent_event_builder.rs @@ -1,6 +1,5 @@ use crate::events::{AgentEvent, StreamState, ToolEvent}; use crate::mcp::tool_bridge::encode_structured; -use llm::{ToolCallError, ToolCallRequest, ToolCallResult}; use serde::Serialize; pub fn agent_event(message_id: &str) -> AgentEventBuilder { @@ -38,15 +37,7 @@ impl AgentEventBuilder { self.push_tool_call(tool_call_id, name, &request_json); - self.chunks.push(AgentEvent::Tool(ToolEvent::Result { - result: ToolCallResult { - id: tool_call_id.to_string(), - name: name.to_string(), - arguments: request_json, - result: encode_structured(&result_value), - }, - result_meta: None, - })); + self.chunks.push(AgentEvent::tool_result(tool_call_id, name, &request_json, &encode_structured(&result_value))); self } @@ -64,14 +55,7 @@ impl AgentEventBuilder { self.push_tool_call(tool_call_id, name, &request_json); - self.chunks.push(AgentEvent::Tool(ToolEvent::Error { - error: ToolCallError { - id: tool_call_id.to_string(), - name: name.to_string(), - arguments: Some(request_json), - error: error_result, - }, - })); + self.chunks.push(AgentEvent::tool_error(tool_call_id, name, &request_json, &error_result)); self } @@ -86,13 +70,7 @@ impl AgentEventBuilder { self.chunks.extend([ AgentEvent::Tool(ToolEvent::InputStarted { id: tool_call_id.to_string(), name: name.to_string() }), AgentEvent::Tool(ToolEvent::InputDelta { id: tool_call_id.to_string(), chunk: arguments.to_string() }), - AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { - id: tool_call_id.to_string(), - name: name.to_string(), - arguments: arguments.to_string(), - }, - }), + AgentEvent::tool_call(tool_call_id, name, arguments), ]); } } diff --git a/crates/aether-evals/src/agents/fake_agent.rs b/crates/aether-evals/src/agents/fake_agent.rs index bbe361ca6..60cc4eeca 100644 --- a/crates/aether-evals/src/agents/fake_agent.rs +++ b/crates/aether-evals/src/agents/fake_agent.rs @@ -1,9 +1,8 @@ use super::agent::{Agent, AgentRunResult, RunError}; use crate::Task; -use aether_core::events::{AgentEvent, StreamState, ToolEvent, TurnOutcome}; +use aether_core::events::{AgentEvent, StreamState, TurnOutcome}; use async_stream::try_stream; use futures::Stream; -use llm::ToolCallResult; use std::path::{Path, PathBuf}; use tokio::fs::{create_dir_all, write}; @@ -27,17 +26,8 @@ impl FakeAgent { } pub fn with_tool_call(tool_name: impl Into, result: impl Into) -> Self { - let tool_name = tool_name.into(); Self::new(vec![ - AgentEvent::Tool(ToolEvent::Result { - result: ToolCallResult { - id: "fake_call_1".to_string(), - name: tool_name, - arguments: "{}".to_string(), - result: result.into(), - }, - result_meta: None, - }), + AgentEvent::tool_result("fake_call_1", &tool_name.into(), "{}", &result.into()), AgentEvent::text("fake_2", "Task completed using tools", StreamState::Complete), AgentEvent::turn_ended(TurnOutcome::Completed), ]) diff --git a/crates/aether-evals/src/agents/transcript.rs b/crates/aether-evals/src/agents/transcript.rs index 309ea8d3b..802d7ffc3 100644 --- a/crates/aether-evals/src/agents/transcript.rs +++ b/crates/aether-evals/src/agents/transcript.rs @@ -139,8 +139,8 @@ mod tests { use super::*; use crate::{Agent, FakeAgent, Task}; use aether_core::events::TurnEvent; + use llm::TokenUsage; use llm::testing::session_usage_event; - use llm::{TokenUsage, ToolCallRequest, ToolCallResult}; #[tokio::test] async fn transcript_from_stream() { @@ -206,20 +206,10 @@ mod tests { } fn tool_call(name: &str) -> AgentEvent { - AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { id: name.to_string(), name: name.to_string(), arguments: "{}".to_string() }, - }) + AgentEvent::tool_call(name, name, "{}") } fn tool_result(name: &str) -> AgentEvent { - AgentEvent::Tool(ToolEvent::Result { - result: ToolCallResult { - id: name.to_string(), - name: name.to_string(), - arguments: "{}".to_string(), - result: "ok".to_string(), - }, - result_meta: None, - }) + AgentEvent::tool_result(name, name, "{}", "ok") } } diff --git a/crates/aether-evals/src/assertions.rs b/crates/aether-evals/src/assertions.rs index b462426e8..831461e47 100644 --- a/crates/aether-evals/src/assertions.rs +++ b/crates/aether-evals/src/assertions.rs @@ -44,12 +44,12 @@ pub fn assert_tool_call_with_args(transcript: &Transcript, name: &str, expected: #[cfg(test)] mod tests { use super::*; - use aether_core::events::{AgentEvent, ToolEvent}; - use llm::ToolCallResult; + use aether_core::events::AgentEvent; #[test] fn assert_tool_call_with_args_accepts_matching_json_arguments() { - let trace = transcript_with_messages(vec![tool_result("bash", r#"{"command":"pwd"}"#)]); + let trace = + transcript_with_messages(vec![AgentEvent::tool_result("bash", "bash", r#"{"command":"pwd"}"#, "ok")]); assert_tool_call_with_args(&trace, "bash", &serde_json::json!({ "command": "pwd" })); } @@ -57,7 +57,7 @@ mod tests { #[test] #[should_panic(expected = "Non-JSON arguments seen for this tool")] fn assert_tool_call_with_args_surfaces_parse_failures() { - let trace = transcript_with_messages(vec![tool_result("bash", "not json")]); + let trace = transcript_with_messages(vec![AgentEvent::tool_result("bash", "bash", "not json", "ok")]); assert_tool_call_with_args(&trace, "bash", &serde_json::json!({ "command": "pwd" })); } @@ -73,16 +73,4 @@ mod tests { fn transcript_with_messages(messages: Vec) -> Transcript { Transcript::new(messages) } - - fn tool_result(name: &str, arguments: &str) -> AgentEvent { - AgentEvent::Tool(ToolEvent::Result { - result: ToolCallResult { - id: name.to_string(), - name: name.to_string(), - arguments: arguments.to_string(), - result: "ok".to_string(), - }, - result_meta: None, - }) - } } diff --git a/crates/aether-evals/src/judge.rs b/crates/aether-evals/src/judge.rs index 0c322d340..99413d012 100644 --- a/crates/aether-evals/src/judge.rs +++ b/crates/aether-evals/src/judge.rs @@ -463,19 +463,13 @@ mod tests { use super::*; use aether_core::events::{AgentEvent, StreamState, TurnOutcome}; use llm::testing::FakeLlmProvider; - use llm::{LlmError, ProviderError, ToolCallRequest, ToolCallResult}; + use llm::{LlmError, ProviderError}; const VALID_RESPONSE: &str = r#"{"criteria":[{"id":"behavior","score":1.0,"reason":"correct"},{"id":"clarity","score":0.5,"reason":"brief"}],"overall_reason":"good"}"#; #[test] fn transcript_lines_label_each_message_kind() { - let call = AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { - id: "call_1".to_string(), - name: "bash".to_string(), - arguments: "{}".to_string(), - }, - }); + let call = AgentEvent::tool_call("call_1", "bash", "{}"); assert_eq!( get_transcript_line(&AgentEvent::text("msg_1", "hi", StreamState::Complete), 100).unwrap(), @@ -494,15 +488,7 @@ mod tests { #[test] fn tool_result_transcript_uses_result_arguments() { - let message = AgentEvent::Tool(ToolEvent::Result { - result: ToolCallResult { - id: "call_1".to_string(), - name: "coding__read_file".to_string(), - arguments: r#"["Cargo.toml"]"#.to_string(), - result: "file contents".to_string(), - }, - result_meta: None, - }); + let message = AgentEvent::tool_result("call_1", "coding__read_file", r#"["Cargo.toml"]"#, "file contents"); assert_eq!(get_transcript_line(&message, 100).unwrap(), "[tool-result] coding__read_file: file contents"); } @@ -537,13 +523,7 @@ mod tests { #[test] fn judge_builder_renders_transcript_context() { let messages = vec![ - AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { - id: "call_1".to_string(), - name: "bash".to_string(), - arguments: "{}".to_string(), - }, - }), + AgentEvent::tool_call("call_1", "bash", "{}"), AgentEvent::text("msg_1", "all done", StreamState::Complete), ]; diff --git a/crates/aether-sessions/src/testing.rs b/crates/aether-sessions/src/testing.rs index 6874e3b8c..127b821f5 100644 --- a/crates/aether-sessions/src/testing.rs +++ b/crates/aether-sessions/src/testing.rs @@ -6,8 +6,8 @@ use std::fs; use std::path::Path; -use aether_core::events::{AgentEvent, ContextEvent, MessageEvent, StreamState, ToolEvent, TurnEvent, TurnOutcome}; -use llm::{ContentBlock, MessageId, ToolCallError, ToolCallRequest, ToolCallResult}; +use aether_core::events::{AgentEvent, ContextEvent, StreamState, TurnOutcome}; +use llm::{ContentBlock, MessageId}; use tempfile::TempDir; use crate::model::{SessionControlEvent, SessionEvent, SessionMeta, UserEvent}; @@ -39,11 +39,7 @@ pub fn user_message_with(content: Vec) -> SessionEvent { /// The complete assistant text for `message_id`. pub fn assistant_text(message_id: &str, text: &str) -> SessionEvent { - SessionEvent::Agent(AgentEvent::Message(MessageEvent::Text { - message_id: message_id.into(), - chunk: text.into(), - is_complete: true, - })) + SessionEvent::Agent(AgentEvent::text(message_id, text, StreamState::Complete)) } /// A streaming assistant text chunk, which is never persisted. @@ -53,29 +49,22 @@ pub fn partial_text(message_id: &str, chunk: &str) -> SessionEvent { /// A tool call issued by the agent. pub fn tool_call(id: &str, name: &str, arguments: &str) -> SessionEvent { - SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { id: id.into(), name: name.into(), arguments: arguments.into() }, - })) + SessionEvent::Agent(AgentEvent::tool_call(id, name, arguments)) } /// A successful tool result for `id`. pub fn tool_result(id: &str, name: &str, result: &str) -> SessionEvent { - SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Result { - result: ToolCallResult { id: id.into(), name: name.into(), arguments: "{}".into(), result: result.into() }, - result_meta: None, - })) + SessionEvent::Agent(AgentEvent::tool_result(id, name, "{}", result)) } /// A failed tool execution for `id`. pub fn tool_error(id: &str, name: &str, error: &str) -> SessionEvent { - SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Error { - error: ToolCallError { id: id.into(), name: name.into(), arguments: Some("{}".into()), error: error.into() }, - })) + SessionEvent::Agent(AgentEvent::tool_error(id, name, "{}", error)) } /// The terminal event of a turn. pub fn turn_ended(outcome: TurnOutcome) -> SessionEvent { - SessionEvent::Agent(AgentEvent::Turn(TurnEvent::Ended { outcome })) + SessionEvent::Agent(AgentEvent::turn_ended(outcome)) } /// A control event recording an agent switch. diff --git a/crates/aether-telemetry/tests/observer_tests.rs b/crates/aether-telemetry/tests/observer_tests.rs index 5df84d712..b0aa3571d 100644 --- a/crates/aether-telemetry/tests/observer_tests.rs +++ b/crates/aether-telemetry/tests/observer_tests.rs @@ -3,7 +3,7 @@ use std::error::Error; use std::time::Duration; use aether_core::core::RetryConfig; -use aether_core::events::{AgentEvent, AgentObserver, LlmCallOutcome, StreamState, ToolEvent, TurnEvent, TurnOutcome}; +use aether_core::events::{AgentEvent, AgentObserver, LlmCallOutcome, StreamState, TurnEvent, TurnOutcome}; use aether_core::testing::{AgentTrace, TestScenario, test_agent}; use aether_telemetry::ContentCaptureSettings; use aether_telemetry::{ @@ -15,10 +15,7 @@ use common::{SYSTEM_INSTRUCTIONS_JSON, SYSTEM_PROMPT, SYSTEM_PROMPT_SHA256, all_ mod common; use llm::testing::llm_response; -use llm::{ - LlmCallPurpose, LlmError, LlmResponse, ModelIdentity, ModelPricing, ProviderError, StopReason, TokenUsage, - ToolCallRequest, -}; +use llm::{LlmCallPurpose, LlmError, LlmResponse, ModelIdentity, ModelPricing, ProviderError, StopReason, TokenUsage}; use opentelemetry::metrics::MeterProvider as _; use opentelemetry::trace::{SpanKind, Status, TracerProvider as _}; use opentelemetry::{Array, Value}; @@ -32,13 +29,7 @@ async fn tool_span_exposes_w3c_parent_context() -> Result<(), Box> { let mut harness = otel_test().redacting().build(); let observer = harness.observer.as_mut().expect("observer still alive"); observer.on_event(&AgentEvent::Turn(TurnEvent::Started { content: vec![] })); - observer.on_event(&AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { - id: "call_1".to_string(), - name: "subagents__spawn_subagent".to_string(), - arguments: "{}".to_string(), - }, - })); + observer.on_event(&AgentEvent::tool_call("call_1", "subagents__spawn_subagent", "{}")); let context = observer.tool_trace_context("call_1").expect("tool span exposes trace context"); harness.drop_observer(); let spans = harness.spans(); diff --git a/crates/aether-telemetry/tests/otlp_collector_tests.rs b/crates/aether-telemetry/tests/otlp_collector_tests.rs index cecb1fbbc..d11a28c73 100644 --- a/crates/aether-telemetry/tests/otlp_collector_tests.rs +++ b/crates/aether-telemetry/tests/otlp_collector_tests.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use aether_core::events::{AgentEvent, LlmCallOutcome, ToolEvent, TraceContext, TurnEvent, TurnOutcome}; +use aether_core::events::{AgentEvent, LlmCallOutcome, TraceContext, TurnEvent, TurnOutcome}; use aether_telemetry::{ AETHER_SYSTEM_INSTRUCTIONS_SHA256, AgentTraceContext, ContentCaptureSettings, GEN_AI_SYSTEM_INSTRUCTIONS, TelemetryConfig, TelemetryRuntime, @@ -14,7 +14,7 @@ use axum::body::Bytes; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::routing::post; -use llm::{LlmCallPurpose, ModelIdentity, ModelPricing, TokenUsage, ToolCallRequest}; +use llm::{LlmCallPurpose, ModelIdentity, ModelPricing, TokenUsage}; use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest; use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; use opentelemetry_proto::tonic::trace::v1::Span; @@ -152,13 +152,7 @@ async fn propagated_mcp_context_connects_parent_tool_server_and_child_agent_span let factory = runtime.observer_factory(); let mut parent = factory.agent(Some("PatchWaveFix"), None); parent.on_event(&AgentEvent::Turn(TurnEvent::Started { content: vec![] })); - parent.on_event(&AgentEvent::Tool(ToolEvent::Call { - request: ToolCallRequest { - id: "call_1".to_string(), - name: "subagents__spawn_subagent".to_string(), - arguments: "{}".to_string(), - }, - })); + parent.on_event(&AgentEvent::tool_call("call_1", "subagents__spawn_subagent", "{}")); let outbound = parent.tool_trace_context("call_1").expect("outbound tool span provides propagation context"); let server = factory.tool_call_request("spawn_subagent", Some(&outbound)); From 7295767c4cd47432f40dbc8b1395f554e2fb7208 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 19:26:24 -0700 Subject: [PATCH 15/26] refactor: dedupe workspace status and file logging across wisp and cli - wisp's WorkspaceStatus is now a re-export of acp-utils' WorkspaceStatusResponse, which had the same fields, with a free initial_workspace_status(cwd) in place of the inherent constructors and the From conversion. - wisp exports init_file_logging(dir, file_name, default_filter) and aether-cli's ACP mode uses it instead of its own copy, dropping aether-cli's tracing-appender dependency. The ACP log file now writes one line per event instead of the multi-line pretty format, and logging setup uses try_init like wisp. --- Cargo.lock | 1 - crates/aether-cli/Cargo.toml | 1 - crates/aether-cli/src/acp/mod.rs | 19 ++-------------- crates/wisp/src/lib.rs | 11 ++++++---- crates/wisp/src/runtime/agent.rs | 4 ++-- crates/wisp/src/session/mod.rs | 6 +++--- crates/wisp/src/session/session_model.rs | 6 +++--- crates/wisp/src/session/workspace_status.rs | 24 +++------------------ crates/wisp/src/testing.rs | 10 ++++----- crates/wisp/tests/runtime.rs | 4 ++-- crates/wisp/tests/tui/status_line.rs | 16 ++++++++------ 11 files changed, 37 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 552cd8797..9fa336cfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,6 @@ dependencies = [ "tokio-tungstenite 0.30.0", "tokio-util", "tracing", - "tracing-appender", "tracing-subscriber", "url", "uuid", diff --git a/crates/aether-cli/Cargo.toml b/crates/aether-cli/Cargo.toml index 73e0c8657..d693d0a15 100644 --- a/crates/aether-cli/Cargo.toml +++ b/crates/aether-cli/Cargo.toml @@ -61,7 +61,6 @@ clap = { workspace = true } schemars = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -tracing-appender = { workspace = true } futures = { workspace = true } dirs = { workspace = true } uuid = { workspace = true } diff --git a/crates/aether-cli/src/acp/mod.rs b/crates/aether-cli/src/acp/mod.rs index ebd5dea1b..963bb2925 100644 --- a/crates/aether-cli/src/acp/mod.rs +++ b/crates/aether-cli/src/acp/mod.rs @@ -25,15 +25,10 @@ use llm::{ProviderConnectionOverride, ProviderConnectionOverrides, ReasoningEffo use std::collections::BTreeMap; use std::env::current_dir; use std::io; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::{ - fs::create_dir_all, - path::{Path, PathBuf}, -}; use thiserror::Error; use tracing::{info, warn}; -use tracing_appender::rolling::daily; -use tracing_subscriber::EnvFilter; use aether_auth::OAuthError; use aether_project::SettingsError; @@ -203,7 +198,7 @@ impl AcpRunConfig { fn create_acp_state(args: AcpArgs, cwd: &Path, hooks: SessionHooks) -> Result { let config = AcpRunConfig::from_args(args)?; - setup_logging(&config.log_dir); + wisp::init_file_logging(&config.log_dir, "aether-acp.log", "warn"); let initial_selection = if let Some(agent) = config.agent.clone() { InitialSessionSelection::agent(agent) @@ -237,16 +232,6 @@ fn create_acp_state(args: AcpArgs, cwd: &Path, hooks: SessionHooks) -> Result PathBuf { PathBuf::from("/tmp/aether-acp-logs") } diff --git a/crates/wisp/src/lib.rs b/crates/wisp/src/lib.rs index d324593c2..81c6ad8ba 100644 --- a/crates/wisp/src/lib.rs +++ b/crates/wisp/src/lib.rs @@ -79,14 +79,17 @@ pub async fn run_with_session(session: Session, settings: UiSettings) -> Result< runtime::run(app, Renderer::new(), event_rx, client_handle).await } -fn setup_logging(log_dir: Option<&Path>) { - let dir = log_dir.unwrap_or_else(|| Path::new(DEFAULT_LOG_DIR)); +pub fn init_file_logging(dir: &Path, file_name: &str, default_filter: &str) { create_dir_all(dir).ok(); let _ = tracing_subscriber::fmt() - .with_writer(daily(dir, "wisp.log")) + .with_writer(daily(dir, file_name)) .with_ansi(false) - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter))) .try_init(); } +fn setup_logging(log_dir: Option<&Path>) { + init_file_logging(log_dir.unwrap_or(Path::new(DEFAULT_LOG_DIR)), "wisp.log", "info"); +} + pub const DEFAULT_LOG_DIR: &str = "/tmp/wisp-logs"; diff --git a/crates/wisp/src/runtime/agent.rs b/crates/wisp/src/runtime/agent.rs index f76cbd2b8..9988dd46a 100644 --- a/crates/wisp/src/runtime/agent.rs +++ b/crates/wisp/src/runtime/agent.rs @@ -1,6 +1,6 @@ use crate::command::{AgentCommand, CommandResult, PromptRejection}; use crate::runtime::tasks::TaskSupervisor; -use crate::session::workspace_status::WorkspaceStatus; +use crate::session::workspace_status::initial_workspace_status; use acp_utils::client::{AcpClientError, AcpClientHandle}; use acp_utils::notifications::{ McpRequest, SessionPreviewParams, WorkspaceListParams, WorkspaceMoveParams, WorkspaceStatusPayload, @@ -76,7 +76,7 @@ pub(super) fn execute( let request = WorkspaceStatusPayload { session_id }; let response = handle.request(request); tasks.submit_network(async move { - let status = response.await.map_or_else(|_| WorkspaceStatus::initial(&cwd), WorkspaceStatus::from); + let status = response.await.unwrap_or_else(|_| initial_workspace_status(&cwd)); CommandResult::WorkspaceResolved { cwd, status } }); } diff --git a/crates/wisp/src/session/mod.rs b/crates/wisp/src/session/mod.rs index 16820f263..0f089a8a1 100644 --- a/crates/wisp/src/session/mod.rs +++ b/crates/wisp/src/session/mod.rs @@ -5,7 +5,7 @@ pub mod terminal; pub mod workspace_status; use crate::error::AppError; -use crate::session::workspace_status::WorkspaceStatus; +use crate::session::workspace_status::{WorkspaceStatus, initial_workspace_status}; use acp_utils::client::{AcpClient, AcpClientError, connect_acp_client, initialize_request}; use acp_utils::notifications::{RemoteServerInfo, SessionPreviewParams}; use agent_client_protocol::schema::v2::{ @@ -54,7 +54,7 @@ impl Session { } else { client.handle.new_session(NewSessionRequest::new(working_dir.clone())).await? }; - let workspace_status = WorkspaceStatus::initial(&working_dir); + let workspace_status = initial_workspace_status(&working_dir); Ok(Self { client, response, working_dir, workspace_status, workspace_access: WorkspaceAccess::Remote }) } @@ -65,7 +65,7 @@ impl Session { } pub async fn connect_to(agent: impl ConnectTo + 'static, working_dir: PathBuf) -> Result { - let workspace_status = WorkspaceStatus::initial(&working_dir); + let workspace_status = initial_workspace_status(&working_dir); let client = connect_acp_client(agent, initialize_request(client_info())).await?; let session_response = client.handle.new_session(NewSessionRequest::new(working_dir.clone())).await?; diff --git a/crates/wisp/src/session/session_model.rs b/crates/wisp/src/session/session_model.rs index a5d04aa3b..a8cc27425 100644 --- a/crates/wisp/src/session/session_model.rs +++ b/crates/wisp/src/session/session_model.rs @@ -1,5 +1,5 @@ use crate::session::session_config_view::{LocalConfigKind, LocalConfigOption}; -use crate::session::workspace_status::WorkspaceStatus; +use crate::session::workspace_status::{WorkspaceStatus, initial_workspace_status}; use crate::session::WorkspaceAccess; use acp_utils::notifications::{AetherCapabilities, McpServerStatus, McpServerStatusEntry}; use agent_client_protocol::schema::v2::{self as acp, SessionId}; @@ -30,7 +30,7 @@ impl SessionModel { } = config; let workspace_status = match workspace_access { WorkspaceAccess::Local => workspace_status, - WorkspaceAccess::Remote => WorkspaceStatus::initial(&working_dir), + WorkspaceAccess::Remote => initial_workspace_status(&working_dir), }; Self { workspace_access, @@ -121,7 +121,7 @@ impl SessionModel { pub fn set_working_dir(&mut self, working_dir: PathBuf) { if self.workspace_access == WorkspaceAccess::Remote { - self.workspace_status = WorkspaceStatus::initial(&working_dir); + self.workspace_status = initial_workspace_status(&working_dir); } self.working_dir = working_dir; } diff --git a/crates/wisp/src/session/workspace_status.rs b/crates/wisp/src/session/workspace_status.rs index a1b1c8798..398d48913 100644 --- a/crates/wisp/src/session/workspace_status.rs +++ b/crates/wisp/src/session/workspace_status.rs @@ -1,6 +1,6 @@ use std::path::Path; -use acp_utils::notifications::WorkspaceStatusResponse; +pub use acp_utils::notifications::WorkspaceStatusResponse as WorkspaceStatus; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum WorkspaceAccess { @@ -9,24 +9,6 @@ pub enum WorkspaceAccess { Remote, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkspaceStatus { - pub display_dir: String, - pub git_ref: Option, -} - -impl WorkspaceStatus { - pub fn new(display_dir: impl Into, git_ref: Option) -> Self { - Self { display_dir: display_dir.into(), git_ref } - } - - pub fn initial(cwd: &Path) -> Self { - Self::new(cwd.display().to_string(), None) - } -} - -impl From for WorkspaceStatus { - fn from(response: WorkspaceStatusResponse) -> Self { - Self::new(response.display_dir, response.git_ref) - } +pub fn initial_workspace_status(cwd: &Path) -> WorkspaceStatus { + WorkspaceStatus { display_dir: cwd.display().to_string(), git_ref: None } } diff --git a/crates/wisp/src/testing.rs b/crates/wisp/src/testing.rs index 560bebf78..637e36069 100644 --- a/crates/wisp/src/testing.rs +++ b/crates/wisp/src/testing.rs @@ -118,7 +118,7 @@ impl FakeExecutor { match command { Command::Agent(AgentCommand::FetchWorkspaceStatus { cwd, .. }) => { let status = self.git.workspace_status(); - Some(CommandResult::WorkspaceResolved { cwd: cwd.clone(), status: status.into() }) + Some(CommandResult::WorkspaceResolved { cwd: cwd.clone(), status }) } Command::GitReview(GitReviewCommand::Open { .. }) => { self.git_completion = None; @@ -1390,10 +1390,10 @@ impl TestUiBuilder { ) .auth_methods(self.auth_methods.clone()), session_response: acp::NewSessionResponse::new("test-session").config_options(self.config_options.clone()), - workspace_status: self - .workspace_status - .clone() - .unwrap_or_else(|| WorkspaceStatus::new("~/code/demo", Some("main".to_string()))), + workspace_status: self.workspace_status.clone().unwrap_or_else(|| WorkspaceStatus { + display_dir: "~/code/demo".to_string(), + git_ref: Some("main".to_string()), + }), working_dir: self.working_dir.clone().unwrap_or_else(|| PathBuf::from(".")), workspace_access: self.workspace_access, settings: self.settings.clone(), diff --git a/crates/wisp/tests/runtime.rs b/crates/wisp/tests/runtime.rs index 1f7f7960a..f4a50b405 100644 --- a/crates/wisp/tests/runtime.rs +++ b/crates/wisp/tests/runtime.rs @@ -12,7 +12,7 @@ use wisp::command::{AgentCommand, Command, CommandResult, GitReviewCommand}; use wisp::file_index::index_files_with_limit; use wisp::git_review::{DiffScope, DocumentUpdate, Event, FileDiff, FileEntry, LIVE_PROTOCOL_VERSION, ServerMessage}; use wisp::runtime::CommandDispatcher; -use wisp::session::workspace_status::WorkspaceStatus; +use wisp::session::workspace_status::initial_workspace_status; #[test] fn file_index_limit_counts_only_indexed_files() { @@ -77,7 +77,7 @@ async fn workspace_status_falls_back_to_the_session_path_without_an_agent() -> R let Some(CommandResult::WorkspaceResolved { cwd, status }) = dispatcher.next_result().await else { panic!("expected workspace status"); }; - assert_eq!(status, WorkspaceStatus::initial(&cwd)); + assert_eq!(status, initial_workspace_status(&cwd)); resolved.push(cwd); } assert!(resolved.contains(&second.path().to_path_buf())); diff --git a/crates/wisp/tests/tui/status_line.rs b/crates/wisp/tests/tui/status_line.rs index 55d6c94ec..67919aa48 100644 --- a/crates/wisp/tests/tui/status_line.rs +++ b/crates/wisp/tests/tui/status_line.rs @@ -498,7 +498,8 @@ fn status_line_unicode_truncation_uses_display_width() { }), ..Default::default() }; - let workspace_status = WorkspaceStatus::new("~/café/longpath/here", Some("main".to_string())); + let workspace_status = + WorkspaceStatus { display_dir: "~/café/longpath/here".to_string(), git_ref: Some("main".to_string()) }; let mut ui = TestUiBuilder::new().settings(settings).workspace_status(workspace_status).dimensions(120, 15).build(); ui.draw(); @@ -600,10 +601,10 @@ fn status_line_two_row_overflow_truncates_with_ellipsis() { }), ..Default::default() }; - let workspace_status = WorkspaceStatus::new( - "~/very-long-directory-name-that-should-be-truncated", - Some("feature/very-long-branch-name".to_string()), - ); + let workspace_status = WorkspaceStatus { + display_dir: "~/very-long-directory-name-that-should-be-truncated".to_string(), + git_ref: Some("feature/very-long-branch-name".to_string()), + }; let mut ui = TestUiBuilder::new().settings(settings).workspace_status(workspace_status).dimensions(20, 15).build(); ui.draw(); @@ -622,7 +623,10 @@ fn status_line_two_row_unicode_overflow_truncates_at_display_width() { }), ..Default::default() }; - let workspace_status = WorkspaceStatus::new("~/日本語の長いディレクトリパス", Some("main".to_string())); + let workspace_status = WorkspaceStatus { + display_dir: "~/日本語の長いディレクトリパス".to_string(), + git_ref: Some("main".to_string()), + }; let mut ui = TestUiBuilder::new().settings(settings).workspace_status(workspace_status).dimensions(20, 15).build(); ui.draw(); From d2990b94de2ee6f2d6bff5b9053bdf77b546a9b4 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 19:32:47 -0700 Subject: [PATCH 16/26] refactor(llm): expand Mantle endpoint templates with utils::Vars expand_api_template and TemplateError re-implemented the ${VAR} expansion utils::Vars already provides. MantleClient now builds a Vars once with the AWS region placeholders and expands catalog templates through it, replacing the region field it only kept for expansion. An unterminated "${" is now left literal rather than erroring, and an empty environment value is substituted rather than treated as missing; the catalog test still asserts every shipped template fully resolves. --- crates/llm/src/catalog/mod.rs | 6 +- crates/llm/src/catalog/transport.rs | 98 ------------------- .../src/providers/bedrock/mantle/client.rs | 22 ++--- 3 files changed, 11 insertions(+), 115 deletions(-) diff --git a/crates/llm/src/catalog/mod.rs b/crates/llm/src/catalog/mod.rs index 64aaa8884..0fe81bb72 100644 --- a/crates/llm/src/catalog/mod.rs +++ b/crates/llm/src/catalog/mod.rs @@ -9,7 +9,7 @@ pub mod transport; pub use crate::usage::ModelPricing; pub use bedrock::BedrockModel; pub use model_spec::{ModelSpec, ModelSpecError, ReasoningEffortError, validate_reasoning_effort}; -pub use transport::{ModelTransport, TemplateError, expand_api_template}; +pub use transport::ModelTransport; include!(concat!(env!("OUT_DIR"), "/generated.rs")); @@ -299,7 +299,9 @@ mod tests { let Some(ModelTransport::OpenAiResponses { base_url_template }) = model.transport() else { continue; }; - let resolved = expand_api_template(base_url_template, |_| Some("test-region".to_string())) + let resolved = utils::variables::Vars::new() + .with_env_lookup(|_| Some("test-region".to_string())) + .expand(base_url_template) .unwrap_or_else(|e| panic!("{model} has an unresolvable endpoint template: {e}")); assert!(resolved.starts_with("https://"), "{model} resolved to '{resolved}'"); assert!(!resolved.contains("${"), "{model} left a placeholder in '{resolved}'"); diff --git a/crates/llm/src/catalog/transport.rs b/crates/llm/src/catalog/transport.rs index 348d05b11..c774da862 100644 --- a/crates/llm/src/catalog/transport.rs +++ b/crates/llm/src/catalog/transport.rs @@ -1,7 +1,3 @@ -use std::fmt::Write as _; - -use thiserror::Error; - /// Executable per-model transport strategy. /// /// A model without an override uses its provider's default transport. Each @@ -11,97 +7,3 @@ pub enum ModelTransport { /// `OpenAI` Responses API at a dedicated endpoint template. OpenAiResponses { base_url_template: &'static str }, } - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum TemplateError { - #[error("template variable '{name}' could not be resolved in '{template}'")] - UnresolvedVariable { name: String, template: String }, - #[error("template '{template}' has an unterminated '${{' placeholder")] - UnterminatedPlaceholder { template: String }, -} - -/// Expand `${VAR}` placeholders in an endpoint template. -/// -/// `lookup` resolves a variable name to its value; returning `None` fails the -/// expansion rather than substituting an empty string, so a misconfigured -/// environment surfaces as an error instead of a malformed URL. -pub fn expand_api_template(template: &str, lookup: impl Fn(&str) -> Option) -> Result { - let mut out = String::with_capacity(template.len()); - let mut rest = template; - - while let Some(start) = rest.find("${") { - out.push_str(&rest[..start]); - let after = &rest[start + 2..]; - let Some(end) = after.find('}') else { - return Err(TemplateError::UnterminatedPlaceholder { template: template.to_string() }); - }; - let name = &after[..end]; - let value = lookup(name).ok_or_else(|| TemplateError::UnresolvedVariable { - name: name.to_string(), - template: template.to_string(), - })?; - let _ = out.write_str(&value); - rest = &after[end + 1..]; - } - - out.push_str(rest); - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn lookup<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { - move |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).to_string()) - } - - #[test] - fn expands_a_single_variable() { - let expanded = expand_api_template( - "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1", - lookup(&[("AWS_REGION", "us-west-2")]), - ) - .unwrap(); - - assert_eq!(expanded, "https://bedrock-mantle.us-west-2.api.aws/openai/v1"); - } - - #[test] - fn leaves_templates_without_placeholders_untouched() { - let expanded = expand_api_template("https://bedrock-mantle.us-east-1.api.aws/v1", lookup(&[])).unwrap(); - - assert_eq!(expanded, "https://bedrock-mantle.us-east-1.api.aws/v1"); - } - - #[test] - fn expands_multiple_variables() { - let expanded = expand_api_template( - "https://${HOST}/v1/projects/${PROJECT}/end", - lookup(&[("HOST", "example.com"), ("PROJECT", "p-1")]), - ) - .unwrap(); - - assert_eq!(expanded, "https://example.com/v1/projects/p-1/end"); - } - - #[test] - fn unresolved_variable_is_an_error() { - let error = expand_api_template("https://x.${AWS_REGION}.aws/v1", lookup(&[])).unwrap_err(); - - assert_eq!( - error, - TemplateError::UnresolvedVariable { - name: "AWS_REGION".to_string(), - template: "https://x.${AWS_REGION}.aws/v1".to_string(), - } - ); - } - - #[test] - fn unterminated_placeholder_is_an_error() { - let error = expand_api_template("https://x.${AWS_REGION/v1", lookup(&[])).unwrap_err(); - - assert!(matches!(error, TemplateError::UnterminatedPlaceholder { .. })); - } -} diff --git a/crates/llm/src/providers/bedrock/mantle/client.rs b/crates/llm/src/providers/bedrock/mantle/client.rs index 38d63a8e2..36eb5a0f5 100644 --- a/crates/llm/src/providers/bedrock/mantle/client.rs +++ b/crates/llm/src/providers/bedrock/mantle/client.rs @@ -1,18 +1,19 @@ use tracing::debug; use super::auth::MantleAuth; -use crate::catalog::transport::{ModelTransport, expand_api_template}; +use crate::catalog::transport::ModelTransport; use crate::providers::http::{SseData, http_client}; use crate::providers::openai_responses::mappers::{ResponsesRequestPolicy, build_wire_request}; use crate::providers::openai_responses::transport::send; use crate::providers::response_stream::OpenedStream; use crate::{Context, LlmError, Result}; +use utils::variables::Vars; /// Transport for Bedrock models served through the `OpenAI` Responses API. #[derive(Clone)] pub struct MantleClient { http: reqwest::Client, - region: String, + template_vars: Vars, auth: MantleAuth, base_url_override: Option, } @@ -21,7 +22,7 @@ impl MantleClient { pub fn new(region: String, auth: MantleAuth, base_url_override: Option) -> Self { Self { http: http_client(), - region, + template_vars: Vars::new().with("AWS_REGION", region.clone()).with("AWS_DEFAULT_REGION", region), auth, base_url_override: base_url_override.map(|url| url.trim_end_matches('/').to_string()), } @@ -40,7 +41,9 @@ impl MantleClient { let ModelTransport::OpenAiResponses { base_url_template } = transport; let base = match &self.base_url_override { Some(base) => base.clone(), - None => self.expand(base_url_template)?, + None => { + self.template_vars.expand(base_url_template).map_err(|e| LlmError::InvalidArgument(e.to_string()))? + } }; Ok(format!("{}/responses", base.trim_end_matches('/'))) @@ -61,17 +64,6 @@ impl MantleClient { let headers = self.auth.headers("POST", &url, &encoded).await?; send(&self.http, &url, headers, body).await } - - /// Resolve a catalog endpoint template. The region this client was built for - /// answers the AWS region placeholders; anything else comes from the process - /// environment. - fn expand(&self, template: &str) -> Result { - expand_api_template(template, |name| match name { - "AWS_REGION" | "AWS_DEFAULT_REGION" => Some(self.region.clone()), - other => std::env::var(other).ok().filter(|value| !value.is_empty()), - }) - .map_err(|e| LlmError::InvalidArgument(e.to_string())) - } } #[cfg(test)] From 0b31c8bbdd5bf169cb3909cd0b8d03344dd09119 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 19:39:02 -0700 Subject: [PATCH 17/26] refactor(llm): build the Codex OAuth request with the oauth2 builder perform_codex_oauth_flow now builds one BasicClient and uses it for both the authorize URL and the token exchange. The authorize URL comes from authorize_url(CsrfToken::new_random) with scopes, PKCE and the Codex extra params instead of a hand-assembled query, the hardcoded endpoint URLs use expect() like CodexTokenManager::new, and callback parsing looks up the two query params it needs. Drop generate_random_state and the tautological URL and constant tests. --- crates/llm/src/providers/codex/oauth.rs | 115 ++++-------------------- 1 file changed, 19 insertions(+), 96 deletions(-) diff --git a/crates/llm/src/providers/codex/oauth.rs b/crates/llm/src/providers/codex/oauth.rs index 6fb1fe97e..557846777 100644 --- a/crates/llm/src/providers/codex/oauth.rs +++ b/crates/llm/src/providers/codex/oauth.rs @@ -5,7 +5,7 @@ use aether_auth::{ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use oauth2::basic::BasicClient; -use oauth2::{AuthUrl, AuthorizationCode, ClientId, PkceCodeChallenge, RedirectUrl, TokenUrl}; +use oauth2::{AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenUrl}; use std::sync::Arc; use tokio::sync::Mutex; use url::Url; @@ -14,30 +14,24 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; const TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; -const SCOPE: &str = "openid profile email offline_access"; +const SCOPES: [&str; 4] = ["openid", "profile", "email", "offline_access"]; /// Run the full Codex OAuth flow: open browser, capture callback, exchange token, save credentials. -/// pub async fn perform_codex_oauth_flow(store: &dyn OAuthCredentialStorage) -> Result<(), LlmError> { + let oauth_client = BasicClient::new(ClientId::new(CLIENT_ID.to_string())) + .set_auth_uri(AuthUrl::new(AUTHORIZE_URL.to_string()).expect("hardcoded Codex authorize URL is valid")) + .set_token_uri(TokenUrl::new(TOKEN_URL.to_string()).expect("hardcoded Codex token URL is valid")) + .set_redirect_uri(RedirectUrl::new(REDIRECT_URI.to_string()).expect("hardcoded Codex redirect URI is valid")); + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - let state = generate_random_state(); - - let auth_url = Url::parse_with_params( - AUTHORIZE_URL, - &[ - ("response_type", "code"), - ("client_id", CLIENT_ID), - ("redirect_uri", REDIRECT_URI), - ("scope", SCOPE), - ("code_challenge", pkce_challenge.as_str()), - ("code_challenge_method", "S256"), - ("state", &state), - ("id_token_add_organizations", "true"), - ("codex_cli_simplified_flow", "true"), - ("originator", "codex_cli_rs"), - ], - ) - .map_err(|e| OAuthError::TokenExchange(format!("Failed to build auth URL: {e}")))?; + let (auth_url, state) = oauth_client + .authorize_url(CsrfToken::new_random) + .add_scopes(SCOPES.map(|scope| Scope::new(scope.to_string()))) + .set_pkce_challenge(pkce_challenge) + .add_extra_param("id_token_add_organizations", "true") + .add_extra_param("codex_cli_simplified_flow", "true") + .add_extra_param("originator", "codex_cli_rs") + .url(); // Port 1455 is hardcoded because the Codex API has a fixed redirect URI // (http://localhost:1455/auth/callback) registered with OpenAI's OAuth server. @@ -45,33 +39,12 @@ pub async fn perform_codex_oauth_flow(store: &dyn OAuthCredentialStorage) -> Res let callback_url = handler.authorize(auth_url.as_str()).await?; let callback = Url::parse(&callback_url) .map_err(|error| OAuthError::InvalidCallback(format!("Invalid callback URL: {error}")))?; - let mut code = None; - let mut callback_state = None; - for (name, value) in callback.query_pairs() { - match name.as_ref() { - "code" => code = Some(value.into_owned()), - "state" => callback_state = Some(value.into_owned()), - _ => {} - } - } - if callback_state.as_deref() != Some(&state) { + let param = |name: &str| callback.query_pairs().find(|(key, _)| key == name).map(|(_, value)| value.into_owned()); + if param("state").as_deref() != Some(state.secret().as_str()) { return Err(OAuthError::StateMismatch.into()); } - let code = code.ok_or_else(|| OAuthError::InvalidCallback("No authorization code in callback".to_string()))?; - - let oauth_client = BasicClient::new(ClientId::new(CLIENT_ID.to_string())) - .set_auth_uri( - AuthUrl::new(AUTHORIZE_URL.to_string()) - .map_err(|e| OAuthError::TokenExchange(format!("invalid auth URL: {e}")))?, - ) - .set_token_uri( - TokenUrl::new(TOKEN_URL.to_string()) - .map_err(|e| OAuthError::TokenExchange(format!("invalid token URL: {e}")))?, - ) - .set_redirect_uri( - RedirectUrl::new(REDIRECT_URI.to_string()) - .map_err(|e| OAuthError::TokenExchange(format!("invalid redirect URI: {e}")))?, - ); + let code = + param("code").ok_or_else(|| OAuthError::InvalidCallback("No authorization code in callback".to_string()))?; let http_client = oauth_http_client()?; @@ -186,10 +159,6 @@ pub fn extract_account_id(access_token: &str) -> Result { Ok(account_id.to_string()) } -fn generate_random_state() -> String { - uuid::Uuid::new_v4().to_string() -} - #[cfg(test)] mod tests { use super::*; @@ -253,52 +222,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn auth_url_is_well_formed() { - let (pkce_challenge, _) = PkceCodeChallenge::new_random_sha256(); - let state = "test-state"; - - let auth_url = Url::parse_with_params( - AUTHORIZE_URL, - &[ - ("response_type", "code"), - ("client_id", CLIENT_ID), - ("redirect_uri", REDIRECT_URI), - ("scope", SCOPE), - ("code_challenge", pkce_challenge.as_str()), - ("code_challenge_method", "S256"), - ("state", state), - ("id_token_add_organizations", "true"), - ("codex_cli_simplified_flow", "true"), - ("originator", "codex_cli_rs"), - ], - ) - .unwrap(); - - let url_str = auth_url.as_str(); - assert!(url_str.starts_with(AUTHORIZE_URL)); - assert!(url_str.contains("client_id=")); - assert!(url_str.contains("redirect_uri=")); - assert!(url_str.contains("scope=")); - assert!(url_str.contains("code_challenge=")); - assert!(url_str.contains("state=test-state")); - } - - #[test] - fn generate_random_state_is_valid_uuid() { - let state = generate_random_state(); - assert_ne!(state, ""); - assert!(uuid::Uuid::parse_str(&state).is_ok()); - } - - #[test] - fn oauth_constants_are_valid() { - assert!(AUTHORIZE_URL.starts_with("https://")); - assert!(TOKEN_URL.starts_with("https://")); - assert!(REDIRECT_URI.starts_with("http://localhost:")); - assert!(SCOPE.contains("openid")); - } - #[tokio::test] async fn codex_token_manager_refreshes_expired_credential() { let new_access_token = test_jwt_for_account("acct_new"); From 5fb2a8106573d03ef17da78d404d36e91452b398 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 19:46:15 -0700 Subject: [PATCH 18/26] refactor(llm): lean on http, thiserror and the Bedrock SDK builders - Anthropic marks the x-api-key header sensitive and logs the headers' Debug output, replacing the hand-rolled redaction helpers and tests. - Codex logs its JSON request body through Value's Display. - ModelSpecError and ReasoningEffortError derive thiserror::Error; InvalidModel's `source: String` becomes `reason` so it is not mistaken for an error source. - Bedrock builds converse_stream with the Option-taking setters and logs the request once. Bedrock's two error classifiers stay separate: event-stream exceptions carry no error code in their metadata, so classifying by code() would make mid-stream throttling non-retryable. --- crates/llm/src/catalog/model_spec.rs | 74 ++++++------------- .../llm/src/providers/anthropic/provider.rs | 65 ++-------------- crates/llm/src/providers/bedrock/provider.rs | 45 +++++------ crates/llm/src/providers/codex/provider.rs | 5 +- 4 files changed, 45 insertions(+), 144 deletions(-) diff --git a/crates/llm/src/catalog/model_spec.rs b/crates/llm/src/catalog/model_spec.rs index 4ceaa5ab6..be97545b3 100644 --- a/crates/llm/src/catalog/model_spec.rs +++ b/crates/llm/src/catalog/model_spec.rs @@ -2,6 +2,7 @@ use super::LlmModel; use crate::ReasoningEffort; use std::fmt; use std::str::FromStr; +use thiserror::Error; /// A validated model selection: a single model or several alloyed models, /// parsed from a comma-separated `provider:model` list. @@ -54,7 +55,7 @@ impl FromStr for ModelSpec { return Err(ModelSpecError::EmptyEntry); } part.parse::() - .map_err(|source| ModelSpecError::InvalidModel { model: part.to_string(), source }) + .map_err(|reason| ModelSpecError::InvalidModel { model: part.to_string(), reason }) }) .collect::, _>>() .map(Self) @@ -63,68 +64,28 @@ impl FromStr for ModelSpec { impl fmt::Display for ModelSpec { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - for (index, model) in self.0.iter().enumerate() { - if index > 0 { - write!(formatter, ",")?; - } - write!(formatter, "{model}")?; - } - Ok(()) + formatter.write_str(&self.0.iter().map(ToString::to_string).collect::>().join(",")) } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ModelSpecError { + #[error("model spec cannot be empty")] Empty, + #[error("model spec contains an empty entry")] EmptyEntry, - InvalidModel { model: String, source: String }, + #[error("invalid model '{model}': {reason}")] + InvalidModel { model: String, reason: String }, } -impl fmt::Display for ModelSpecError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => write!(formatter, "model spec cannot be empty"), - Self::EmptyEntry => write!(formatter, "model spec contains an empty entry"), - Self::InvalidModel { model, source } => write!(formatter, "invalid model '{model}': {source}"), - } - } -} - -impl std::error::Error for ModelSpecError {} - -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ReasoningEffortError { - InvalidSpec(ModelSpecError), + #[error("{0}")] + InvalidSpec(#[from] ModelSpecError), + #[error("model '{model}' does not support reasoning{}", supported_efforts(*effort, supported))] Unsupported { model: String, effort: ReasoningEffort, supported: Vec }, } -impl fmt::Display for ReasoningEffortError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidSpec(source) => write!(formatter, "{source}"), - Self::Unsupported { model, supported, .. } if supported.is_empty() => { - write!(formatter, "model '{model}' does not support reasoning") - } - Self::Unsupported { model, effort, supported } => { - let supported = supported.iter().map(ToString::to_string).collect::>().join(", "); - write!( - formatter, - "model '{model}' does not support reasoning effort '{effort}'; supported: {supported}" - ) - } - } - } -} - -impl std::error::Error for ReasoningEffortError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::InvalidSpec(source) => Some(source), - Self::Unsupported { .. } => None, - } - } -} - /// Validate `effort` against every model in a comma-separated model spec. pub fn validate_reasoning_effort( model_spec: &str, @@ -133,8 +94,15 @@ pub fn validate_reasoning_effort( if effort.is_none() { return Ok(()); } - let spec = model_spec.parse::().map_err(ReasoningEffortError::InvalidSpec)?; - spec.validate_reasoning_effort(effort) + model_spec.parse::()?.validate_reasoning_effort(effort) +} + +fn supported_efforts(effort: ReasoningEffort, supported: &[ReasoningEffort]) -> String { + if supported.is_empty() { + return String::new(); + } + let supported = supported.iter().map(ToString::to_string).collect::>().join(", "); + format!(" effort '{effort}'; supported: {supported}") } #[cfg(test)] diff --git a/crates/llm/src/providers/anthropic/provider.rs b/crates/llm/src/providers/anthropic/provider.rs index aca98dd3e..3f30ca5d9 100644 --- a/crates/llm/src/providers/anthropic/provider.rs +++ b/crates/llm/src/providers/anthropic/provider.rs @@ -124,8 +124,9 @@ impl AnthropicProvider { headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); if self.auth_mode != ProviderAuthMode::None { - let api_key = self.get_api_key()?; - headers.insert("x-api-key", HeaderValue::from_str(&api_key)?); + let mut api_key = HeaderValue::from_str(&self.get_api_key()?)?; + api_key.set_sensitive(true); + headers.insert("x-api-key", api_key); } Ok(headers) } @@ -140,7 +141,7 @@ impl AnthropicProvider { serde_json::to_string(&request).unwrap_or_else(|_| "".to_string()) ); - debug!("Anthropic request headers: {}", format_headers(&headers)); + debug!(?headers, "Anthropic request headers"); open_sse(self.client.post(&url).headers(headers).json(&request), anthropic_code).await } } @@ -199,25 +200,6 @@ fn effort_to_budget_tokens(effort: ReasoningEffort) -> Option { }) } -fn should_redact_header(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower == "authorization" || lower == "x-api-key" || lower.contains("secret") || lower.contains("token") -} - -fn format_headers(headers: &header::HeaderMap) -> String { - let mut parts = Vec::new(); - for (name, value) in headers { - let name_str = name.as_str(); - let value_str = if should_redact_header(name_str) { - "".to_string() - } else { - value.to_str().unwrap_or("").to_string() - }; - parts.push(format!("{name_str}={value_str}")); - } - parts.join(", ") -} - #[cfg(test)] mod tests { use super::*; @@ -286,6 +268,8 @@ mod tests { let provider = AnthropicProvider::new(Some("test-api-key".to_string())); let headers = provider.build_headers().expect("headers"); assert_eq!(headers.get("x-api-key").and_then(|value| value.to_str().ok()), Some("test-api-key")); + assert!(headers.get("x-api-key").is_some_and(HeaderValue::is_sensitive)); + assert!(!format!("{headers:?}").contains("test-api-key")); assert!(headers.get(AUTHORIZATION).is_none()); assert!(headers.get("anthropic-beta").is_none()); } @@ -432,41 +416,4 @@ mod tests { let provider = AnthropicProvider::new(Some("test-api-key".to_string())); assert_eq!(provider.display_name(), "Anthropic (claude-sonnet-4-5-20250929)"); } - - #[test] - fn format_headers_redacts_x_api_key() { - let mut headers = HeaderMap::new(); - headers.insert("x-api-key", HeaderValue::from_static("sk-secret-123")); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - - let formatted = format_headers(&headers); - assert!(formatted.contains("x-api-key=")); - assert!(formatted.contains("content-type=application/json")); - assert!(!formatted.contains("sk-secret-123")); - } - - #[test] - fn format_headers_redacts_authorization() { - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer token123")); - - let formatted = format_headers(&headers); - assert!(formatted.contains("authorization=")); - assert!(!formatted.contains("token123")); - } - - #[test] - fn format_headers_redacts_secret_and_token_headers() { - let mut headers = HeaderMap::new(); - headers.insert("x-client-secret", HeaderValue::from_static("mysecret")); - headers.insert("x-auth-token", HeaderValue::from_static("mytoken")); - headers.insert("accept", HeaderValue::from_static("text/plain")); - - let formatted = format_headers(&headers); - assert!(formatted.contains("x-client-secret=")); - assert!(formatted.contains("x-auth-token=")); - assert!(formatted.contains("accept=text/plain")); - assert!(!formatted.contains("mysecret")); - assert!(!formatted.contains("mytoken")); - } } diff --git a/crates/llm/src/providers/bedrock/provider.rs b/crates/llm/src/providers/bedrock/provider.rs index 469149f74..30005fb44 100644 --- a/crates/llm/src/providers/bedrock/provider.rs +++ b/crates/llm/src/providers/bedrock/provider.rs @@ -127,39 +127,28 @@ impl BedrockProvider { let (system_blocks, messages) = map_messages(context.messages(), cache_point.as_ref())?; let settings = context.model_settings(); let max_tokens = settings.max_tokens.and_then(|m| i32::try_from(m).ok()).unwrap_or(DEFAULT_MAX_TOKENS); - let mut inference_config = InferenceConfiguration::builder().max_tokens(max_tokens); - - if let Some(temp) = settings.temperature { - inference_config = inference_config.temperature(temp); - } - - if let Some(top_p) = settings.top_p { - inference_config = inference_config.top_p(top_p); - } - - let inference_config = inference_config.build(); + let inference_config = InferenceConfiguration::builder() + .max_tokens(max_tokens) + .set_temperature(settings.temperature) + .set_top_p(settings.top_p) + .build(); + let tool_config = + (!context.tools().is_empty()).then(|| map_tools(context.tools(), cache_point.as_ref())).transpose()?; + + info!( + model = %self.model, + inference_profile_arn = ?self.inference_profile_arn, + "Sending Bedrock converse_stream request" + ); - let mut request = self + let request = self .client .converse_stream() .model_id(self.request_model_id()) .set_messages(Some(messages)) - .inference_config(inference_config); - - if !system_blocks.is_empty() { - request = request.set_system(Some(system_blocks)); - } - - if !context.tools().is_empty() { - let tool_config = map_tools(context.tools(), cache_point.as_ref())?; - request = request.tool_config(tool_config); - } - - if let Some(arn) = self.inference_profile_arn.as_deref() { - info!(model = %self.model, inference_profile_arn = %arn, "Sending Bedrock converse_stream request"); - } else { - info!(model = %self.model, "Sending Bedrock converse_stream request"); - } + .set_system((!system_blocks.is_empty()).then_some(system_blocks)) + .inference_config(inference_config) + .set_tool_config(tool_config); let response = request.send().await.map_err(|e| { error!(model = %self.model, error = ?e, "Bedrock API error"); diff --git a/crates/llm/src/providers/codex/provider.rs b/crates/llm/src/providers/codex/provider.rs index 653ac2c7f..eb9462f2c 100644 --- a/crates/llm/src/providers/codex/provider.rs +++ b/crates/llm/src/providers/codex/provider.rs @@ -82,10 +82,7 @@ impl CodexProvider { let url = format!("{}/responses", self.base_url); debug!("Sending request to Codex API: {url}"); - debug!( - "Codex request body: {}", - serde_json::to_string(&request).unwrap_or_else(|_| "".to_string()) - ); + debug!("Codex request body: {request}"); match send(&self.client, &url, headers, request).await { Ok(opened) => Ok(opened), From 5bb14cbf597a9cd7aba8e83a4a776430a064d513 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 19:52:47 -0700 Subject: [PATCH 19/26] refactor(aether-auth): encrypt credentials with age::encrypt / decrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encrypted credential file hand-rolled age's Encryptor / Decryptor plumbing with a map_err per step. Use age's one-shot encrypt / decrypt helpers, keeping the scrypt work-factor override. Decryption failures now share one "Decryption failed — wrong passphrase or corrupted file" message, which still carries age's specific cause. --- crates/aether-auth/src/encrypted_file.rs | 38 +++++------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/crates/aether-auth/src/encrypted_file.rs b/crates/aether-auth/src/encrypted_file.rs index 771451f53..df4e16dc4 100644 --- a/crates/aether-auth/src/encrypted_file.rs +++ b/crates/aether-auth/src/encrypted_file.rs @@ -1,16 +1,14 @@ use crate::credential::OAuthCredentialStorage; use crate::error::OAuthError; -use age::scrypt::{Identity, Recipient as ScryptRecipient}; +use age::scrypt::{Identity, Recipient}; use age::secrecy::SecretString; -use age::{Decryptor, Encryptor}; use async_trait::async_trait; use dirs::home_dir; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::env::var; -use std::io::{Read, Write}; -use std::iter::once; +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -52,39 +50,17 @@ impl EncryptedFileOAuthCredentialStorage { } fn encrypt(plaintext: &[u8], passphrase: &str, work_factor: Option) -> Result, OAuthError> { - let fail = |e| OAuthError::CredentialStore(format!("Encryption failed: {e}")); - - let mut recipient = ScryptRecipient::new(SecretString::from(passphrase)); + let mut recipient = Recipient::new(SecretString::from(passphrase)); if let Some(log_n) = work_factor { recipient.set_work_factor(log_n); } - - let encryptor = Encryptor::with_recipients(once(&recipient as &dyn age::Recipient)) - .map_err(|e| OAuthError::CredentialStore(format!("Encryption failed: {e}")))?; - - let mut ciphertext = Vec::new(); - let mut writer = encryptor.wrap_output(&mut ciphertext).map_err(fail)?; - writer.write_all(plaintext).map_err(fail)?; - writer.finish().map_err(fail)?; - - Ok(ciphertext) + age::encrypt(&recipient, plaintext).map_err(|e| OAuthError::CredentialStore(format!("Encryption failed: {e}"))) } fn decrypt(ciphertext: &[u8], passphrase: &str) -> Result, OAuthError> { - let decryptor = Decryptor::new(ciphertext) - .map_err(|e| OAuthError::CredentialStore(format!("Invalid encrypted file: {e}")))?; - - let mut reader = - decryptor.decrypt(once(&Identity::new(SecretString::from(passphrase)) as &dyn age::Identity)).map_err( - |e| OAuthError::CredentialStore(format!("Decryption failed — wrong passphrase or corrupted file: {e}")), - )?; - - let mut plaintext = Vec::new(); - reader - .read_to_end(&mut plaintext) - .map_err(|e| OAuthError::CredentialStore(format!("Decryption failed: {e}")))?; - - Ok(plaintext) + age::decrypt(&Identity::new(SecretString::from(passphrase)), ciphertext).map_err(|e| { + OAuthError::CredentialStore(format!("Decryption failed — wrong passphrase or corrupted file: {e}")) + }) } fn load_store(&self) -> Result { From 9bdf8e79165af347e87a30058bbb31b241780bec Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 20:04:54 -0700 Subject: [PATCH 20/26] refactor(aether-cli): run the init wizard on ratatui::try_init WizardTerminal hand-rolled raw mode, the alternate screen, cursor hiding and keyboard enhancement plus their teardown. It now wraps ratatui::try_init() and calls ratatui::restore() on drop; ratatui's Terminal shows the cursor again when it drops. Keyboard enhancement is gone since the wizard only reads arrows, Space, Enter and Esc. The single- and multi-select loops and their render functions collapse into one Picker driven by run_picker; single-select treats the cursor as the selection. --- crates/aether-cli/src/init/tui_runner.rs | 223 +++++++++-------------- 1 file changed, 84 insertions(+), 139 deletions(-) diff --git a/crates/aether-cli/src/init/tui_runner.rs b/crates/aether-cli/src/init/tui_runner.rs index a25e999fb..3c6d3e91b 100644 --- a/crates/aether-cli/src/init/tui_runner.rs +++ b/crates/aether-cli/src/init/tui_runner.rs @@ -2,21 +2,14 @@ use super::InitError; use super::build_settings::{Preset, supported_providers}; use super::harness::HarnessIntegration; use clap::ValueEnum; -use crossterm::cursor::{Hide, Show}; -use crossterm::event::{ - Event, EventStream, KeyCode, KeyEventKind, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, -}; -use crossterm::execute; -use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind}; use futures::StreamExt; use llm::catalog::Provider; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; +use ratatui::DefaultTerminal; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::Paragraph; -use std::io::{self, Stdout}; +use std::io; pub async fn run_wizard( provider: Option, @@ -33,7 +26,7 @@ pub async fn run_wizard( let provider_options = supported_providers().map(|provider| (format_provider_title(provider), provider)).collect(); let provider = match provider { Some(provider) => provider, - None => match run_select(&mut terminal, &mut events, "Choose a provider:", provider_options).await? { + None => match pick_one(&mut terminal, &mut events, "Choose a provider:", provider_options).await? { Some(provider) => provider, None => return Ok(None), }, @@ -41,14 +34,14 @@ pub async fn run_wizard( let preset = match preset { Some(preset) => preset, - None => match run_select(&mut terminal, &mut events, "Choose a preset:", preset_options()).await? { + None => match pick_one(&mut terminal, &mut events, "Choose a preset:", preset_options()).await? { Some(preset) => preset, None => return Ok(None), }, }; let harnesses = if preset == Preset::BatteriesIncluded && harnesses.is_empty() { - match run_multi_select(&mut terminal, &mut events).await? { + match pick_harnesses(&mut terminal, &mut events).await? { Some(harnesses) => harnesses, None => return Ok(None), } @@ -59,59 +52,22 @@ pub async fn run_wizard( Ok(Some((provider, preset, harnesses))) } -struct WizardTerminal { - terminal: Terminal>, - keyboard_enhancement: bool, -} +struct WizardTerminal(DefaultTerminal); impl WizardTerminal { fn enter() -> io::Result { - enable_raw_mode()?; - - let mut stdout = io::stdout(); - if let Err(error) = execute!(stdout, EnterAlternateScreen, Hide) { - let _ = disable_raw_mode(); - return Err(error); - } - - let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; - let keyboard_enhancement = if execute!(stdout, PushKeyboardEnhancementFlags(flags)).is_ok() { - true - } else { - execute!(stdout, PopKeyboardEnhancementFlags).is_err() - }; - - match Terminal::new(CrosstermBackend::new(stdout)) { - Ok(terminal) => Ok(Self { terminal, keyboard_enhancement }), - Err(error) => { - let mut stdout = io::stdout(); - if keyboard_enhancement { - let _ = execute!(stdout, PopKeyboardEnhancementFlags); - } - let _ = execute!(stdout, Show, LeaveAlternateScreen); - let _ = disable_raw_mode(); - Err(error) - } - } + ratatui::try_init().map(Self).inspect_err(|_| ratatui::restore()) } fn draw(&mut self, lines: Vec>) -> io::Result<()> { - self.terminal.draw(|frame| { - frame.render_widget(Paragraph::new(lines), frame.area()); - })?; + self.0.draw(|frame| frame.render_widget(Paragraph::new(lines), frame.area()))?; Ok(()) } } impl Drop for WizardTerminal { fn drop(&mut self) { - if self.keyboard_enhancement { - let _ = execute!(self.terminal.backend_mut(), PopKeyboardEnhancementFlags); - } - let _ = execute!(self.terminal.backend_mut(), Show, LeaveAlternateScreen); - let _ = disable_raw_mode(); + ratatui::restore(); } } @@ -121,13 +77,51 @@ struct SelectOption { value: T, } -struct MultiSelect { +struct Picker { options: Vec>, - selected: Vec, + toggled: Vec, cursor: usize, + multi: bool, } -async fn run_multi_select( +impl Picker { + fn is_selected(&self, index: usize) -> bool { + if self.multi { self.toggled[index] } else { index == self.cursor } + } + + fn lines(&self, header: &str) -> Vec> { + let mut lines = header_lines(header); + lines.extend(self.options.iter().enumerate().map(|(index, option)| { + let marker = match (self.multi, self.is_selected(index)) { + (true, true) => "[x] ", + (true, false) => "[ ] ", + (false, true) => "● ", + (false, false) => "○ ", + }; + let description = + option.description.as_deref().map(|description| format!(" - {description}")).unwrap_or_default(); + let style = if self.multi && index == self.cursor { + primary_style().add_modifier(Modifier::BOLD) + } else if self.is_selected(index) { + primary_style() + } else { + Style::default() + }; + Line::styled(format!("{marker}{}{description}", option.title), style) + })); + + let footer = if self.multi { + " ↑/↓ to move · Space to toggle · Enter to confirm · Esc to cancel" + } else { + " ↑/↓ to move · Enter to confirm · Esc to cancel" + }; + + lines.extend(footer_lines(footer)); + lines + } +} + +async fn pick_harnesses( terminal: &mut WizardTerminal, events: &mut EventStream, ) -> Result>, InitError> { @@ -139,12 +133,31 @@ async fn run_multi_select( value: *harness, }) .collect::>(); - let mut select = MultiSelect { selected: vec![true; options.len()], options, cursor: 0 }; - let header = "Load prompts, skills, and rules from other harness conventions?"; - let footer = " ↑/↓ to move · Space to toggle · Enter to confirm · Esc to cancel"; + let picker = Picker { toggled: vec![true; options.len()], options, cursor: 0, multi: true }; + run_picker(terminal, events, "Load prompts, skills, and rules from other harness conventions?", picker).await +} +async fn pick_one( + terminal: &mut WizardTerminal, + events: &mut EventStream, + header: &str, + options: Vec<(String, T)>, +) -> Result, InitError> { + let options = + options.into_iter().map(|(title, value)| SelectOption { title, description: None, value }).collect::>(); + let picker = Picker { toggled: vec![false; options.len()], options, cursor: 0, multi: false }; + let chosen = run_picker(terminal, events, header, picker).await?; + Ok(chosen.and_then(|values| values.into_iter().next())) +} + +async fn run_picker( + terminal: &mut WizardTerminal, + events: &mut EventStream, + header: &str, + mut picker: Picker, +) -> Result>, InitError> { loop { - terminal.draw(render_multi_select(&select, header, footer)).map_err(InitError::Terminal)?; + terminal.draw(picker.lines(header)).map_err(InitError::Terminal)?; let Some(event) = next_event(events).await? else { return Ok(None); @@ -153,26 +166,23 @@ async fn run_multi_select( continue; }; + let len = picker.options.len(); match key.code { KeyCode::Esc => return Ok(None), KeyCode::Enter => { - let selected = select + let selected = (0..len).map(|index| picker.is_selected(index)).collect::>(); + let chosen = picker .options .into_iter() - .zip(select.selected) - .filter_map(|(option, selected)| selected.then_some(option.value)) - .collect(); - return Ok(Some(selected)); - } - KeyCode::Char(' ') if !select.options.is_empty() => { - select.selected[select.cursor] = !select.selected[select.cursor]; - } - KeyCode::Up if !select.options.is_empty() => { - select.cursor = (select.cursor + select.options.len() - 1) % select.options.len(); + .zip(selected) + .filter_map(|(option, chosen)| chosen.then_some(option.value)); + return Ok(Some(chosen.collect())); } - KeyCode::Down if !select.options.is_empty() => { - select.cursor = (select.cursor + 1) % select.options.len(); + KeyCode::Char(' ') if picker.multi && len > 0 => { + picker.toggled[picker.cursor] = !picker.toggled[picker.cursor]; } + KeyCode::Up if len > 0 => picker.cursor = (picker.cursor + len - 1) % len, + KeyCode::Down if len > 0 => picker.cursor = (picker.cursor + 1) % len, _ => {} } } @@ -196,41 +206,6 @@ fn format_provider_title(provider: Provider) -> String { format!("{:<12} {marker}", provider.display_name()) } -async fn run_select( - terminal: &mut WizardTerminal, - events: &mut EventStream, - prompt: &str, - options: Vec<(String, T)>, -) -> Result, InitError> { - let options = - options.into_iter().map(|(title, value)| SelectOption { title, description: None, value }).collect::>(); - let mut selected = 0; - let footer = " ↑/↓ to move · Enter to confirm · Esc to cancel"; - - loop { - terminal.draw(render_select(&options, selected, prompt, footer)).map_err(InitError::Terminal)?; - - let Some(event) = next_event(events).await? else { - return Ok(None); - }; - let Event::Key(key) = event else { - continue; - }; - - match key.code { - KeyCode::Esc => return Ok(None), - KeyCode::Enter => return Ok(options.into_iter().nth(selected).map(|option| option.value)), - KeyCode::Up if !options.is_empty() => { - selected = (selected + options.len() - 1) % options.len(); - } - KeyCode::Down if !options.is_empty() => { - selected = (selected + 1) % options.len(); - } - _ => {} - } - } -} - async fn next_event(events: &mut EventStream) -> Result, InitError> { loop { let Some(event) = events.next().await else { @@ -244,36 +219,6 @@ async fn next_event(events: &mut EventStream) -> Result, InitError } } -fn render_select(options: &[SelectOption], selected: usize, header: &str, footer: &str) -> Vec> { - let mut lines = header_lines(header); - lines.extend(options.iter().enumerate().map(|(index, option)| { - let marker = if index == selected { "● " } else { "○ " }; - let style = if index == selected { primary_style() } else { Style::default() }; - Line::styled(format!("{marker}{}", option.title), style) - })); - lines.extend(footer_lines(footer)); - lines -} - -fn render_multi_select(select: &MultiSelect, header: &str, footer: &str) -> Vec> { - let mut lines = header_lines(header); - lines.extend(select.options.iter().enumerate().map(|(index, option)| { - let marker = if select.selected[index] { "[x] " } else { "[ ] " }; - let description = - option.description.as_deref().map(|description| format!(" - {description}")).unwrap_or_default(); - let style = if index == select.cursor { - primary_style().add_modifier(Modifier::BOLD) - } else if select.selected[index] { - primary_style() - } else { - Style::default() - }; - Line::styled(format!("{marker}{}{description}", option.title), style) - })); - lines.extend(footer_lines(footer)); - lines -} - fn header_lines(header: &str) -> Vec> { vec![Line::styled(header.to_string(), primary_style()), Line::default()] } From 36d2915d97a8600bf95951e78924232fbfdf238e Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 20:18:16 -0700 Subject: [PATCH 21/26] refactor(aether-lspd): frame language server stdio with an LspCodec The process transport hand-rolled Content-Length framing on a reader task feeding an mpsc channel, dispatched untyped JSON, and wrote frames by hand. It now holds a FramedRead / FramedWrite over the child's stdio with one LspCodec, decodes messages into a typed Incoming, and sends through actor methods. FramedRead::next() is cancel-safe in select!, so the reader task and its channel are gone. A decode error would end the FramedRead stream, so the codec logs and skips frames with an invalid JSON body or no Content-Length, as the old reader loop did. The fake LSP server gains --garble-before to cover this end to end. --- crates/aether-lspd/src/process_transport.rs | 266 ++++++++---------- .../tests/common/fake_lsp_server.py | 10 +- crates/aether-lspd/tests/common/mod.rs | 1 + .../tests/e2e/e2e_error_handling.rs | 14 + 4 files changed, 136 insertions(+), 155 deletions(-) diff --git a/crates/aether-lspd/src/process_transport.rs b/crates/aether-lspd/src/process_transport.rs index dc09f9b23..650200216 100644 --- a/crates/aether-lspd/src/process_transport.rs +++ b/crates/aether-lspd/src/process_transport.rs @@ -3,6 +3,7 @@ use crate::file_watcher::{FileWatcherBatch, FileWatcherHandle}; use crate::language_catalog::{DiagnosticsMode, LspConfig}; use crate::path_to_uri; use crate::protocol::{LspErrorResponse, LspNotification}; +use futures::{SinkExt, StreamExt}; use lsp_types::notification::{DidChangeWatchedFiles, Initialized, Notification, PublishDiagnostics}; use lsp_types::request::{ Initialize, RegisterCapability, Request, UnregisterCapability, WorkDoneProgressCreate, WorkspaceDiagnosticRefresh, @@ -18,14 +19,16 @@ use lsp_types::{DocumentSymbolClientCapabilities, DynamicRegistrationClientCapab use nix::sys::signal::{Signal, kill}; #[cfg(unix)] use nix::unistd::Pid; -use serde_json::Value; +use serde::Deserialize; +use serde_json::{Value, json}; use std::collections::HashMap; -use std::io::ErrorKind; +use std::io::{self, ErrorKind}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::{mpsc, oneshot, watch}; +use tokio_util::bytes::{Buf, BytesMut}; +use tokio_util::codec::{Decoder, Encoder, FramedRead, FramedWrite}; const INITIALIZE_REQUEST_ID: i64 = 1; @@ -67,10 +70,10 @@ enum TransportCommand { struct ProcessTransportActor { process: Child, - stdin: ChildStdin, + reader: FramedRead, + writer: FramedWrite, diagnostics_mode: DiagnosticsMode, initialized_tx: watch::Sender, - message_rx: mpsc::Receiver, command_rx: mpsc::Receiver, event_tx: mpsc::Sender, watcher: FileWatcherHandle, @@ -100,17 +103,15 @@ impl ProcessTransport { let (command_tx, command_rx) = mpsc::channel(100); let (event_tx, event_rx) = mpsc::channel(100); let (watcher_tx, watcher_rx) = mpsc::channel(64); - let (message_tx, message_rx) = mpsc::channel(100); let (initialized_tx, initialized_rx) = watch::channel(false); let watcher = FileWatcherHandle::spawn(root_path.to_path_buf(), watcher_tx); - tokio::spawn(read_lsp_messages(BufReader::new(stdout), message_tx)); let actor = ProcessTransportActor { process, - stdin, + reader: FramedRead::new(stdout, LspCodec), + writer: FramedWrite::new(stdin, LspCodec), diagnostics_mode: config.diagnostics_mode, initialized_tx, - message_rx, command_rx, event_tx, watcher, @@ -175,12 +176,11 @@ impl ProcessTransportActor { loop { tokio::select! { - msg = self.message_rx.recv() => { - match msg { - Some(message) => self.handle_lsp_message(message).await, - None => break, - } - } + message = self.reader.next() => match message { + Some(Ok(message)) => self.handle_lsp_message(message).await, + Some(Err(err)) => tracing::warn!(%err, "Error reading LSP message"), + None => break, + }, Some(command) = self.command_rx.recv() => { if !self.handle_command(command).await { break; @@ -214,7 +214,7 @@ impl ProcessTransportActor { true } TransportCommand::Notification(notification) => { - if let Err(err) = send_notification(&mut self.stdin, ¬ification.method, notification.params).await { + if let Err(err) = self.send_notification(¬ification.method, notification.params).await { tracing::warn!(%err, "Failed to forward LSP notification"); } true @@ -226,9 +226,8 @@ impl ProcessTransportActor { } } - async fn initialize(&mut self, root_path: &Path) -> std::io::Result<()> { - let root_uri = - crate::path_to_uri(root_path).map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?; + async fn initialize(&mut self, root_path: &Path) -> io::Result<()> { + let root_uri = crate::path_to_uri(root_path).map_err(|err| io::Error::new(ErrorKind::InvalidInput, err))?; let pull_diagnostics = self.diagnostics_mode == DiagnosticsMode::Pull; let capabilities = ClientCapabilities { @@ -269,7 +268,7 @@ impl ProcessTransportActor { #[allow(deprecated)] root_uri: Some(root_uri), workspace_folders: Some(vec![WorkspaceFolder { - uri: path_to_uri(root_path).map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, + uri: path_to_uri(root_path).map_err(|err| io::Error::new(ErrorKind::InvalidInput, err))?, name: root_path.file_name().and_then(|name| name.to_str()).unwrap_or("workspace").to_string(), }]), capabilities, @@ -278,82 +277,59 @@ impl ProcessTransportActor { let mut params = serde_json::to_value(¶ms).unwrap(); if pull_diagnostics { // lsp-types serializes this as `workspace.diagnostic`, but the spec (and servers) read `workspace.diagnostics`. - params["capabilities"]["workspace"]["diagnostics"] = serde_json::json!({ "refreshSupport": true }); + params["capabilities"]["workspace"]["diagnostics"] = json!({ "refreshSupport": true }); } self.send_request(INITIALIZE_REQUEST_ID, Initialize::METHOD, params).await?; - while let Some(message) = self.message_rx.recv().await { - if message.get("id").and_then(Value::as_i64) == Some(INITIALIZE_REQUEST_ID) { - if let Some(error) = message.get("error") { - return Err(std::io::Error::other(format!("server rejected `initialize`: {error}"))); + while let Some(message) = self.reader.next().await { + let message = match message { + Ok(message) => message, + Err(err) => { + tracing::warn!(%err, "Error reading LSP message"); + continue; } - return send_notification(&mut self.stdin, Initialized::METHOD, serde_json::json!({})).await; + }; + if message.id.as_ref().and_then(Value::as_i64) == Some(INITIALIZE_REQUEST_ID) { + if let Some(error) = message.error { + return Err(io::Error::other(format!( + "server rejected `initialize`: {} (code {})", + error.message, error.code + ))); + } + return self.send_notification(Initialized::METHOD, json!({})).await; } self.handle_lsp_message(message).await; } - Err(std::io::Error::new(ErrorKind::UnexpectedEof, "LSP closed during initialization")) + Err(io::Error::new(ErrorKind::UnexpectedEof, "LSP closed during initialization")) } - async fn handle_lsp_message(&mut self, message: Value) { - let has_id = message.get("id").is_some(); - let method = message.get("method").and_then(Value::as_str); - - match (has_id, method) { - (true, Some(method)) => { - let id = message.get("id").cloned().unwrap_or(Value::Null); - let params = message.get("params").cloned().unwrap_or(Value::Null); - match method { - RegisterCapability::METHOD => { - self.handle_register_capability(&id, ¶ms).await; - } - UnregisterCapability::METHOD => { - self.handle_unregister_capability(&id, ¶ms).await; - } - WorkDoneProgressCreate::METHOD => { - let _ = self.send_ok_response(&id).await; - } - WorkspaceDiagnosticRefresh::METHOD => { - let _ = self.send_ok_response(&id).await; - let _ = self.event_tx.send(TransportEvent::DiagnosticsRefreshRequested).await; - } - _ => { - let response = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32601, - "message": format!("Method not found: {method}") - } - }); - let _ = write_lsp_message(&mut self.stdin, &response).await; - } + async fn handle_lsp_message(&mut self, message: Incoming) { + match (message.id, message.method) { + (Some(id), Some(method)) => match method.as_str() { + RegisterCapability::METHOD => self.handle_register_capability(&id, message.params).await, + UnregisterCapability::METHOD => self.handle_unregister_capability(&id, message.params).await, + WorkDoneProgressCreate::METHOD => { + let _ = self.send_ok_response(&id).await; } - } - (true, None) => { - if let Some(id) = message.get("id").and_then(Value::as_i64) - && let Some(tx) = self.pending.remove(&id) - { - let result = if let Some(error) = message.get("error") { - let code = error - .get("code") - .and_then(Value::as_i64) - .and_then(|code| i32::try_from(code).ok()) - .unwrap_or(-1); - let message = - error.get("message").and_then(Value::as_str).unwrap_or("Unknown error").to_string(); - Err(TransportError::Lsp(LspErrorResponse { code, message })) - } else { - Ok(message.get("result").cloned().unwrap_or(Value::Null)) - }; - let _ = tx.send(result); + WorkspaceDiagnosticRefresh::METHOD => { + let _ = self.send_ok_response(&id).await; + let _ = self.event_tx.send(TransportEvent::DiagnosticsRefreshRequested).await; + } + _ => { + let error = json!({ "code": -32601, "message": format!("Method not found: {method}") }); + let _ = self.writer.send(json!({ "jsonrpc": "2.0", "id": id, "error": error })).await; + } + }, + (Some(id), None) => { + if let Some(tx) = id.as_i64().and_then(|id| self.pending.remove(&id)) { + let _ = tx.send(message.error.map_or(Ok(message.result), |error| Err(TransportError::Lsp(error)))); } } - (false, Some(method)) if method == PublishDiagnostics::METHOD => { - let params = message.get("params").cloned().unwrap_or(Value::Null); - if let Ok(diagnostics) = serde_json::from_value(params) { + (None, Some(method)) if method == PublishDiagnostics::METHOD => { + if let Ok(diagnostics) = serde_json::from_value(message.params) { let _ = self.event_tx.send(TransportEvent::PublishedDiagnostics(diagnostics)).await; } } @@ -361,8 +337,8 @@ impl ProcessTransportActor { } } - async fn handle_register_capability(&mut self, id: &Value, params: &Value) { - if let Ok(registration_params) = serde_json::from_value::(params.clone()) { + async fn handle_register_capability(&mut self, id: &Value, params: Value) { + if let Ok(registration_params) = serde_json::from_value::(params) { for registration in ®istration_params.registrations { if registration.method == DidChangeWatchedFiles::METHOD && let Some(options) = ®istration.register_options @@ -376,8 +352,8 @@ impl ProcessTransportActor { let _ = self.send_ok_response(id).await; } - async fn handle_unregister_capability(&mut self, id: &Value, params: &Value) { - if let Ok(unregistration_params) = serde_json::from_value::(params.clone()) { + async fn handle_unregister_capability(&mut self, id: &Value, params: Value) { + if let Ok(unregistration_params) = serde_json::from_value::(params) { for registration in &unregistration_params.unregisterations { if registration.method == DidChangeWatchedFiles::METHOD { self.watcher.unregister(registration.id.clone()); @@ -388,23 +364,16 @@ impl ProcessTransportActor { let _ = self.send_ok_response(id).await; } - async fn send_request(&mut self, id: i64, method: &str, params: Value) -> std::io::Result<()> { - let msg = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params - }); - write_lsp_message(&mut self.stdin, &msg).await + async fn send_request(&mut self, id: i64, method: &str, params: Value) -> io::Result<()> { + self.writer.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).await + } + + async fn send_notification(&mut self, method: &str, params: Value) -> io::Result<()> { + self.writer.send(json!({ "jsonrpc": "2.0", "method": method, "params": params })).await } - async fn send_ok_response(&mut self, id: &Value) -> std::io::Result<()> { - let response = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": null - }); - write_lsp_message(&mut self.stdin, &response).await + async fn send_ok_response(&mut self, id: &Value) -> io::Result<()> { + self.writer.send(json!({ "jsonrpc": "2.0", "id": id, "result": null })).await } fn cleanup_pending(&mut self) { @@ -434,68 +403,57 @@ fn resolve_command(root_path: &Path, command: &str) -> PathBuf { if local_command.exists() { local_command } else { command_path.to_path_buf() } } -async fn send_notification(stdin: &mut ChildStdin, method: &str, params: Value) -> std::io::Result<()> { - let msg = serde_json::json!({ - "jsonrpc": "2.0", - "method": method, - "params": params - }); - write_lsp_message(stdin, &msg).await +#[derive(Deserialize)] +struct Incoming { + id: Option, + method: Option, + #[serde(default)] + params: Value, + #[serde(default)] + result: Value, + error: Option, } -/// Read frames sequentially on a dedicated task and forward complete messages. -/// -/// `read_line` and `read_exact` are not cancellation-safe, so a read that gets cancelled mid-frame -/// causes later responses to be silently lost. -async fn read_lsp_messages(mut reader: BufReader, message_tx: mpsc::Sender) { - loop { - match read_lsp_message(&mut reader).await { - Ok(Some(message)) => { - if message_tx.send(message).await.is_err() { - break; - } +struct LspCodec; + +impl Decoder for LspCodec { + type Item = Incoming; + type Error = io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> io::Result> { + loop { + let Some(header_len) = src.windows(4).position(|window| window == b"\r\n\r\n") else { + return Ok(None); + }; + let body_start = header_len + 4; + let content_length = String::from_utf8_lossy(&src[..header_len]) + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")?.trim().parse::().ok()); + let Some(content_length) = content_length else { + tracing::warn!("Skipping LSP message without a Content-Length header"); + src.advance(body_start); + continue; + }; + if src.len() < body_start + content_length { + src.reserve(body_start + content_length - src.len()); + return Ok(None); } - Ok(None) => break, - Err(err) => { - tracing::warn!(%err, "Error reading LSP message"); + let frame = src.split_to(body_start + content_length); + match serde_json::from_slice(&frame[body_start..]) { + Ok(message) => return Ok(Some(message)), + Err(err) => tracing::warn!(%err, "Skipping invalid LSP message"), } } } } -async fn read_lsp_message(reader: &mut BufReader) -> std::io::Result> { - let mut content_length: Option = None; - - loop { - let mut header = String::new(); - let bytes = reader.read_line(&mut header).await?; - if bytes == 0 { - return Ok(None); - } +impl Encoder for LspCodec { + type Error = io::Error; - let header = header.trim(); - if header.is_empty() { - break; - } - - if let Some(value) = header.strip_prefix("Content-Length: ") { - content_length = value.parse().ok(); - } + fn encode(&mut self, message: Value, dst: &mut BytesMut) -> io::Result<()> { + let body = serde_json::to_vec(&message)?; + dst.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()); + dst.extend_from_slice(&body); + Ok(()) } - - let content_length = - content_length.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing Content-Length"))?; - - let mut buf = vec![0u8; content_length]; - reader.read_exact(&mut buf).await?; - - serde_json::from_slice(&buf).map(Some).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) -} - -async fn write_lsp_message(stdin: &mut ChildStdin, msg: &Value) -> std::io::Result<()> { - let content = serde_json::to_string(msg)?; - let header = format!("Content-Length: {}\r\n\r\n", content.len()); - stdin.write_all(header.as_bytes()).await?; - stdin.write_all(content.as_bytes()).await?; - stdin.flush().await } diff --git a/crates/aether-lspd/tests/common/fake_lsp_server.py b/crates/aether-lspd/tests/common/fake_lsp_server.py index 000b93402..e228e2a2c 100644 --- a/crates/aether-lspd/tests/common/fake_lsp_server.py +++ b/crates/aether-lspd/tests/common/fake_lsp_server.py @@ -9,11 +9,13 @@ parser.add_argument("--wedge-on", action="append", default=[]) parser.add_argument("--crash-on", action="append", default=[]) parser.add_argument("--fail-on", action="append", default=[]) +parser.add_argument("--garble-before", action="append", default=[]) parser.add_argument("--pull-diagnostics", action="store_true") cli_args = parser.parse_args() wedge_methods = set(cli_args.wedge_on) crash_methods = set(cli_args.crash_on) fail_methods = set(cli_args.fail_on) +garble_methods = set(cli_args.garble_before) pull_diagnostics = cli_args.pull_diagnostics refresh_supported = False root_path = None @@ -22,7 +24,10 @@ def write_message(msg): - body = json.dumps(msg, separators=(",", ":")).encode("utf-8") + write_frame(json.dumps(msg, separators=(",", ":")).encode("utf-8")) + + +def write_frame(body): header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") sys.stdout.buffer.write(header) sys.stdout.buffer.write(body) @@ -172,6 +177,9 @@ def make_symbol(name, kind, uri, line): if method in wedge_methods: continue + if method in garble_methods: + write_frame(b"{not json") + if method == "initialize": root_path = uri_to_path(params["rootUri"]) capabilities = {"hoverProvider": True} diff --git a/crates/aether-lspd/tests/common/mod.rs b/crates/aether-lspd/tests/common/mod.rs index a75101466..1c8673017 100644 --- a/crates/aether-lspd/tests/common/mod.rs +++ b/crates/aether-lspd/tests/common/mod.rs @@ -19,6 +19,7 @@ pub fn use_fake_servers() { configure_fake_server(LanguageId::TypeScript, &["--pull-diagnostics"]); configure_fake_server(LanguageId::Go, &["--crash-on", "initialize"]); configure_fake_server(LanguageId::C, &["--fail-on", "initialize"]); + configure_fake_server(LanguageId::Python, &["--garble-before", "textDocument/hover"]); }); } diff --git a/crates/aether-lspd/tests/e2e/e2e_error_handling.rs b/crates/aether-lspd/tests/e2e/e2e_error_handling.rs index eadd8053f..8f5b33c33 100644 --- a/crates/aether-lspd/tests/e2e/e2e_error_handling.rs +++ b/crates/aether-lspd/tests/e2e/e2e_error_handling.rs @@ -19,3 +19,17 @@ async fn daemon_survives_client_drop() { harness.kill().await.expect("Failed to kill daemon"); } + +#[tokio::test] +async fn invalid_server_message_does_not_break_the_session() { + use_fake_servers(); + + let project = cargo_project("garbled_message"); + let harness = DaemonHarness::spawn(project.root(), LanguageId::Python).await.expect("Failed to spawn daemon"); + let client = harness.connect().await.expect("Failed to connect"); + + let hover = client.hover(file_uri(&project.path("src/main.rs")), 0, 0).await.expect("Hover failed"); + assert!(hover.is_some(), "the response after an invalid server message should still arrive"); + + harness.kill().await.expect("Failed to kill daemon"); +} From 9b58d8a94002971585becb8057ac9184b830aeba Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 20:29:50 -0700 Subject: [PATCH 22/26] refactor(aether-lspd): lock the daemon pid file with File::try_lock PidLockfile::acquire now uses std's File::try_lock instead of an unsafe, unix-only libc::flock call, keeping the WouldBlock "held by another process" error. The lock now also applies on non-unix targets. Drop the test-only read_pid, is_process_running and is_stale helpers and their tests, and assert the error kind when a second acquire fails. --- crates/aether-lspd/src/pid_lockfile.rs | 91 ++++---------------------- 1 file changed, 14 insertions(+), 77 deletions(-) diff --git a/crates/aether-lspd/src/pid_lockfile.rs b/crates/aether-lspd/src/pid_lockfile.rs index 2f5334286..183167af6 100644 --- a/crates/aether-lspd/src/pid_lockfile.rs +++ b/crates/aether-lspd/src/pid_lockfile.rs @@ -1,5 +1,5 @@ -use std::fs::{File, OpenOptions, create_dir_all, remove_file}; -use std::io::{self, Write}; +use std::fs::{File, OpenOptions, TryLockError, create_dir_all, remove_file}; +use std::io::{self, Seek, Write}; use std::path::{Path, PathBuf}; use std::process; @@ -11,33 +11,23 @@ pub struct PidLockfile { impl PidLockfile { /// Acquire a lockfile, writing the current PID - /// - /// Uses `flock` for advisory locking. Only one process can hold the lock. pub fn acquire(path: &Path) -> io::Result { - use std::io::Seek; - if let Some(parent) = path.parent() { create_dir_all(parent)?; } - let file = { - let mut file = OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?; - - #[cfg(unix)] - { - use std::os::unix::io::AsRawFd; - let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; - if result != 0 { - return Err(io::Error::new(io::ErrorKind::WouldBlock, "Lockfile is held by another process")); - } + let mut file = OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?; + file.try_lock().map_err(|error| match error { + TryLockError::WouldBlock => { + io::Error::new(io::ErrorKind::WouldBlock, "Lockfile is held by another process") } + TryLockError::Error(error) => error, + })?; - file.set_len(0)?; - file.seek(io::SeekFrom::Start(0))?; - write!(file, "{}", process::id())?; - file.flush()?; - file - }; + file.set_len(0)?; + file.seek(io::SeekFrom::Start(0))?; + write!(file, "{}", process::id())?; + file.flush()?; Ok(Self { path: path.to_path_buf(), _file: file }) } @@ -49,38 +39,6 @@ impl Drop for PidLockfile { } } -#[cfg(test)] -fn read_pid(path: &Path) -> Option { - use std::io::Read; - - let mut file = File::open(path).ok()?; - let mut contents = String::new(); - file.read_to_string(&mut contents).ok()?; - contents.trim().parse().ok() -} - -#[cfg(test)] -fn is_process_running(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(pid.cast_signed(), 0) == 0 } - } - #[cfg(not(unix))] - { - false - } -} - -impl PidLockfile { - #[cfg(test)] - pub fn is_stale(path: &Path) -> bool { - let Some(pid) = read_pid(path) else { - return true; - }; - !is_process_running(pid) - } -} - #[cfg(test)] mod tests { use super::*; @@ -95,8 +53,7 @@ mod tests { let _lock = PidLockfile::acquire(&lock_path).unwrap(); assert!(lock_path.exists()); - let pid = read_pid(&lock_path).unwrap(); - assert_eq!(pid, process::id()); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), process::id().to_string()); } assert!(!lock_path.exists()); @@ -109,26 +66,6 @@ mod tests { let _lock = PidLockfile::acquire(&lock_path).unwrap(); let result = PidLockfile::acquire(&lock_path); - assert!(result.is_err()); - } - - #[test] - fn test_is_stale_nonexistent() { - let temp = TempDir::new().unwrap(); - let lock_path = temp.path().join("nonexistent.lock"); - - assert!(PidLockfile::is_stale(&lock_path)); - } - - #[test] - fn test_is_process_running_self() { - let pid = process::id(); - assert!(is_process_running(pid)); - } - - #[test] - fn test_is_process_running_invalid() { - let fake_pid = 4_000_000_000u32; - assert!(!is_process_running(fake_pid)); + assert_eq!(result.err().map(|error| error.kind()), Some(io::ErrorKind::WouldBlock)); } } From c91e1a302dbb21fc09cd57f3f054ab5a7d3b5abb Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 20:41:20 -0700 Subject: [PATCH 23/26] refactor(aether-telemetry): propagate trace context through a HashMap opentelemetry implements Injector and Extractor for HashMap, so the W3cCarrier adapter and its trait impls are unnecessary. Inject into a map and take traceparent / tracestate out of it; extract from a map built from the two TraceContext fields. --- crates/aether-telemetry/src/trace_context.rs | 52 +++----------------- 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/crates/aether-telemetry/src/trace_context.rs b/crates/aether-telemetry/src/trace_context.rs index 994c39876..6b0119d5c 100644 --- a/crates/aether-telemetry/src/trace_context.rs +++ b/crates/aether-telemetry/src/trace_context.rs @@ -1,10 +1,11 @@ use aether_core::events::{TRACEPARENT_KEY, TRACESTATE_KEY, TraceContext}; use opentelemetry::Context; -use opentelemetry::propagation::{Extractor, Injector, TextMapPropagator}; +use opentelemetry::propagation::TextMapPropagator; use opentelemetry::trace::TraceContextExt; use opentelemetry_sdk::propagation::TraceContextPropagator; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] @@ -27,55 +28,16 @@ pub(crate) fn inject_trace_context(context: &Context) -> Option { return None; } - let mut carrier = W3cCarrier::default(); + let mut carrier = HashMap::new(); TraceContextPropagator::new().inject_context(context, &mut carrier); - Some(TraceContext { traceparent: carrier.traceparent?, tracestate: carrier.tracestate }) + Some(TraceContext { traceparent: carrier.remove(TRACEPARENT_KEY)?, tracestate: carrier.remove(TRACESTATE_KEY) }) } /// The remote span `trace_context` names, for parenting local spans beneath it. /// `None` when the traceparent does not name a valid span. pub(crate) fn extract_trace_context(trace_context: &TraceContext) -> Option { - let context = TraceContextPropagator::new().extract(&W3cCarrier::from(trace_context)); + let mut carrier = HashMap::from([(TRACEPARENT_KEY.to_string(), trace_context.traceparent.clone())]); + carrier.extend(trace_context.tracestate.clone().map(|tracestate| (TRACESTATE_KEY.to_string(), tracestate))); + let context = TraceContextPropagator::new().extract(&carrier); context.span().span_context().is_valid().then_some(context) } - -/// Adapts [`TraceContext`] to the propagator's carrier API, which the orphan -/// rule keeps us from implementing on `TraceContext` itself. -#[derive(Default)] -struct W3cCarrier { - traceparent: Option, - tracestate: Option, -} - -impl From<&TraceContext> for W3cCarrier { - fn from(trace_context: &TraceContext) -> Self { - Self { traceparent: Some(trace_context.traceparent.clone()), tracestate: trace_context.tracestate.clone() } - } -} - -impl Injector for W3cCarrier { - fn set(&mut self, key: &str, value: String) { - match key { - TRACEPARENT_KEY => self.traceparent = Some(value), - TRACESTATE_KEY => self.tracestate = Some(value), - _ => {} - } - } -} - -impl Extractor for W3cCarrier { - fn get(&self, key: &str) -> Option<&str> { - match key { - TRACEPARENT_KEY => self.traceparent.as_deref(), - TRACESTATE_KEY => self.tracestate.as_deref(), - _ => None, - } - } - - fn keys(&self) -> Vec<&str> { - [(TRACEPARENT_KEY, &self.traceparent), (TRACESTATE_KEY, &self.tracestate)] - .into_iter() - .filter_map(|(key, value)| value.is_some().then_some(key)) - .collect() - } -} From a83f71a519185c32a62895f5d52751318ad2b2e2 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 20:49:50 -0700 Subject: [PATCH 24/26] refactor: derive path-or-object settings through PathOrObject PromptSource and McpSourceSpec hand-wrote Serialize, Deserialize and JsonSchema around utils' Deserialize-only PathOrObject. PathOrObject now derives all three, and both types use serde(from/into) and schemars(with) on it, like ToolExposure, leaving a pair of From impls each. string_or_object_schema is gone. Schema change: PromptSource and McpSourceSpec become anyOf [ResourcePath, ] instead of oneOf [string, ]. ResourcePath is a string, names and descriptions are unchanged, the website loader handles anyOf, and the generated SDK types still typecheck. --- crates/aether-core/src/core/prompt.rs | 52 +++++++------------- crates/aether-project/src/mcp_source_spec.rs | 48 +++++++----------- crates/utils/src/lib.rs | 2 +- crates/utils/src/resource_path.rs | 10 +--- 4 files changed, 37 insertions(+), 75 deletions(-) diff --git a/crates/aether-core/src/core/prompt.rs b/crates/aether-core/src/core/prompt.rs index 921ffc3cb..1efbe0edb 100644 --- a/crates/aether-core/src/core/prompt.rs +++ b/crates/aether-core/src/core/prompt.rs @@ -1,8 +1,8 @@ use crate::core::{AgentError, Result}; use crate::mcp::PROGRESSIVE_DISCOVERY_INSTRUCTIONS; use glob::glob; -use schemars::{JsonSchema, Schema, SchemaGenerator}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use thiserror::Error; @@ -11,7 +11,7 @@ use tracing::warn; use utils::shell_expander::ShellExpander; use utils::substitution::substitute_parameters; use utils::variables::VarError; -use utils::{PathOrObject, ResourcePath, string_or_object_schema}; +use utils::{PathOrObject, ResourcePath}; #[derive(Debug, Clone, PartialEq)] pub enum Prompt { @@ -30,7 +30,9 @@ pub struct McpInstructions { /// /// Used by configuration layers to declare prompts before resolution. Convert into /// runtime [`Prompt`] values with [`Prompt::from_sources`]. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(from = "PathOrObject", into = "PathOrObject")] +#[schemars(with = "PathOrObject", description = include_str!("../docs/prompt_source.md"))] pub enum PromptSource { Text { text: String }, File { path: ResourcePath, optional: bool }, @@ -94,41 +96,23 @@ impl From for PromptSource { } } -impl<'de> Deserialize<'de> for PromptSource { - fn deserialize>(deserializer: T) -> std::result::Result { - Ok(match PathOrObject::::deserialize(deserializer)? { - PathOrObject::Path(path) => Self::File { path, optional: false }, +impl From> for PromptSource { + fn from(source: PathOrObject) -> Self { + match source { + PathOrObject::Path(path) => Self::file(path), PathOrObject::Object(object) => object.into(), - }) - } -} - -impl Serialize for PromptSource { - fn serialize(&self, serializer: T) -> std::result::Result { - match self { - Self::File { path, optional: false } => path.serialize(serializer), - Self::File { path, optional } => { - Serialize::serialize(&PromptSourceObject::File { path: path.clone(), optional: *optional }, serializer) - } - Self::Text { text } => Serialize::serialize(&PromptSourceObject::Text { text: text.clone() }, serializer), - Self::Glob { pattern, optional } => Serialize::serialize( - &PromptSourceObject::Glob { pattern: pattern.clone(), optional: *optional }, - serializer, - ), } } } -impl JsonSchema for PromptSource { - fn schema_name() -> std::borrow::Cow<'static, str> { - "PromptSource".into() - } - - fn json_schema(generator: &mut SchemaGenerator) -> Schema { - string_or_object_schema( - include_str!("../docs/prompt_source.md"), - &generator.subschema_for::().to_value(), - ) +impl From for PathOrObject { + fn from(source: PromptSource) -> Self { + match source { + PromptSource::File { path, optional: false } => Self::Path(path), + PromptSource::File { path, optional } => Self::Object(PromptSourceObject::File { path, optional }), + PromptSource::Text { text } => Self::Object(PromptSourceObject::Text { text }), + PromptSource::Glob { pattern, optional } => Self::Object(PromptSourceObject::Glob { pattern, optional }), + } } } diff --git a/crates/aether-project/src/mcp_source_spec.rs b/crates/aether-project/src/mcp_source_spec.rs index 6fcdb70bb..d8e6a7e3e 100644 --- a/crates/aether-project/src/mcp_source_spec.rs +++ b/crates/aether-project/src/mcp_source_spec.rs @@ -1,12 +1,14 @@ use crate::SettingsError; use aether_core::mcp::{McpConfig, McpServerConfig}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use utils::variables::VarError; -use utils::{PathOrObject, ResourcePath, string_or_object_schema}; +use utils::{PathOrObject, ResourcePath}; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(from = "PathOrObject", into = "PathOrObject")] +#[schemars(with = "PathOrObject", description = include_str!("docs/mcp_source_spec.md"))] pub enum McpSourceSpec { File(McpFileSpec), Inline(McpConfig), @@ -100,46 +102,30 @@ impl From for McpSourceSpec { } } -impl<'de> Deserialize<'de> for McpSourceSpec { - fn deserialize>(deserializer: T) -> Result { - Ok(match PathOrObject::::deserialize(deserializer)? { - PathOrObject::Path(path) => Self::File(McpFileSpec::new(path)), +impl From> for McpSourceSpec { + fn from(source: PathOrObject) -> Self { + match source { + PathOrObject::Path(path) => Self::file(path), PathOrObject::Object(McpSourceSpecObject::File { path, defer_tools, optional }) => { Self::File(McpFileSpec { path, defer_tools, optional }) } PathOrObject::Object(McpSourceSpecObject::Inline { servers }) => Self::Inline(McpConfig::new(servers)), - }) + } } } -impl Serialize for McpSourceSpec { - fn serialize(&self, serializer: T) -> Result { - match self { - Self::File(McpFileSpec { path, defer_tools: false, optional: false }) => path.serialize(serializer), - Self::File(McpFileSpec { path, defer_tools, optional }) => Serialize::serialize( - &McpSourceSpecObject::File { path: path.clone(), defer_tools: *defer_tools, optional: *optional }, - serializer, - ), - Self::Inline(config) => { - Serialize::serialize(&McpSourceSpecObject::Inline { servers: config.servers.clone() }, serializer) +impl From for PathOrObject { + fn from(source: McpSourceSpec) -> Self { + match source { + McpSourceSpec::File(McpFileSpec { path, defer_tools: false, optional: false }) => Self::Path(path), + McpSourceSpec::File(McpFileSpec { path, defer_tools, optional }) => { + Self::Object(McpSourceSpecObject::File { path, defer_tools, optional }) } + McpSourceSpec::Inline(config) => Self::Object(McpSourceSpecObject::Inline { servers: config.servers }), } } } -impl schemars::JsonSchema for McpSourceSpec { - fn schema_name() -> std::borrow::Cow<'static, str> { - "McpSourceSpec".into() - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - string_or_object_schema( - include_str!("docs/mcp_source_spec.md"), - &generator.subschema_for::().to_value(), - ) - } -} - #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)] #[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] enum McpSourceSpecObject { diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 764f33982..6dc7d173e 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -27,5 +27,5 @@ pub mod variables; pub use name_pattern::matches_name_pattern; pub use path::home_relative_path; pub use reasoning::ReasoningEffort; -pub use resource_path::{PathOrObject, ResourcePath, string_or_object_schema}; +pub use resource_path::{PathOrObject, ResourcePath}; pub use settings::SettingsStore; diff --git a/crates/utils/src/resource_path.rs b/crates/utils/src/resource_path.rs index 40245b19d..1f09e7f4b 100644 --- a/crates/utils/src/resource_path.rs +++ b/crates/utils/src/resource_path.rs @@ -3,21 +3,13 @@ use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -#[derive(serde::Deserialize)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum PathOrObject { Path(ResourcePath), Object(O), } -pub fn string_or_object_schema(description: &str, object_schema: &serde_json::Value) -> Schema { - Schema::try_from(serde_json::json!({ - "description": description, - "oneOf": [{ "type": "string" }, object_schema], - })) - .expect("string_or_object schema must be valid") -} - /// A path declared in user-facing settings. #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] From e110e08676312a381c0a4a2e7aa38f946fcd4061 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Mon, 12 Oct 2026 20:56:46 -0700 Subject: [PATCH 25/26] refactor(wisp): build styles from colors and tuples ratatui implements From, From<(Color, Color)>, From<(Color, Modifier)> and From<(Color, Color, Modifier)> for Style as exactly the equivalent fg / bg / add_modifier chains. Pass colors and tuples directly where an Into