use anyhow::Result;
use tokio::fs;
use crate::common::{FileAssert, TestProject};
use crate::test_config;
async fn create_repo_with_model_agent(project: &TestProject) -> Result<(String, String)> {
let repo = project.create_source_repo("test-repo").await?;
let agent_content = r#"---
model: gpt-4
temperature: "0.5"
---
# Test Agent
This is a test agent with a model field.
"#;
repo.add_resource("agents", "model-agent", agent_content).await?;
repo.commit_all("Initial commit with model agent")?;
repo.tag_version("v1.0.0")?;
let url = repo.bare_file_url(project.sources_path())?;
Ok((url, "agents/model-agent.md".to_string()))
}
async fn create_repo_with_json_mcp(project: &TestProject) -> Result<(String, String)> {
let repo = project.create_source_repo("mcp-repo").await?;
let mcp_dir = repo.path.join("mcp-servers");
fs::create_dir_all(&mcp_dir).await?;
let mcp_content = r#"{
"name": "test-server",
"command": "npx",
"args": ["@test/server"],
"timeout": 30
}"#;
let mcp_file = mcp_dir.join("test-server.json");
fs::write(&mcp_file, mcp_content).await?;
repo.commit_all("Add test MCP server")?;
repo.tag_version("v1.0.0")?;
let url = repo.bare_file_url(project.sources_path())?;
Ok((url, "mcp-servers/test-server.json".to_string()))
}
#[tokio::test]
async fn test_install_with_project_patches() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
let installed_path = project.project_path().join(".claude/agents/model-agent.md");
FileAssert::exists(&installed_path).await;
let content = fs::read_to_string(&installed_path).await.unwrap();
assert!(
content.contains("model: claude-3-haiku"),
"Expected patched model value 'claude-3-haiku' in:\n{}",
content
);
assert!(!content.contains("model: gpt-4"), "Original model value 'gpt-4' should be replaced");
let lockfile_content = project.read_lockfile().await.unwrap();
assert!(
lockfile_content.contains("applied_patches"),
"Lockfile should contain applied_patches field"
);
assert!(
lockfile_content.contains("model = \"claude-3-haiku\""),
"Lockfile should track the patched model value"
);
}
#[tokio::test]
async fn test_install_with_private_patches() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "gpt-4"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let private_manifest = r#"[patch.agents.my-agent]
temperature = "0.8"
max_tokens = 4000
"#;
let private_path = project.project_path().join("agpm.private.toml");
fs::write(&private_path, private_manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
let installed_path = project.project_path().join(".claude/agents/model-agent.md");
let content = fs::read_to_string(&installed_path).await.unwrap();
assert!(
content.contains("model: gpt-4"),
"Project patch (model) should be applied. Content:\n{}",
content
);
assert!(
content.contains("0.8"),
"Private patch (temperature) should be applied. Content:\n{}",
content
);
assert!(
content.contains("4000"),
"Private patch (max_tokens) should be applied. Content:\n{}",
content
);
}
#[tokio::test]
async fn test_patch_conflict_fails() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "gpt-4"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let private_manifest = r#"[patch.agents.my-agent]
model = "claude-3-haiku"
"#;
let private_path = project.project_path().join("agpm.private.toml");
fs::write(&private_path, private_manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
assert!(
output.success,
"Install should succeed when private patches override project patches. Stderr:\n{}",
output.stderr
);
let agent_path = project.project_path().join(".claude/agents/model-agent.md");
assert!(agent_path.exists(), "Agent should be installed");
let content = fs::read_to_string(&agent_path).await.unwrap();
assert!(
content.contains("model: claude-3-haiku") || content.contains("model: 'claude-3-haiku'"),
"Agent should have private patch applied (claude-3-haiku). Content:\n{}",
content
);
assert!(
!content.contains("model: gpt-4"),
"Agent should not have project patch (gpt-4). Content:\n{}",
content
);
}
#[tokio::test]
async fn test_patch_validation_unknown_alias() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.nonexistent-agent]
model = "claude-3-haiku"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
assert!(!output.success, "Install should fail for unknown alias in patch");
assert!(
output.stderr.contains("nonexistent-agent") || output.stdout.contains("nonexistent-agent"),
"Error should mention the unknown alias"
);
}
#[tokio::test]
async fn test_patches_in_lockfile() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
temperature = "0.7"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
let lockfile_content = project.read_lockfile().await.unwrap();
assert!(
!lockfile_content.contains("[agents.applied_patches]"),
"applied_patches should be inline table, not separate table section.\nLockfile:\n{}",
lockfile_content
);
assert!(
lockfile_content.contains("applied_patches = {"),
"applied_patches should use inline table syntax.\nLockfile:\n{}",
lockfile_content
);
let lockfile: toml::Value = toml::from_str(&lockfile_content).unwrap();
let agents = lockfile.get("agents").and_then(|v| v.as_array()).unwrap();
assert_eq!(agents.len(), 1, "Should have exactly one agent in lockfile");
let agent = &agents[0];
let patches = agent.get("applied_patches").expect("applied_patches field should exist");
assert_eq!(
patches.get("model").and_then(|v| v.as_str()),
Some("claude-3-haiku"),
"Lockfile should track model patch"
);
assert_eq!(
patches.get("temperature").and_then(|v| v.as_str()),
Some("0.7"),
"Lockfile should track temperature patch"
);
let output2 = project.run_agpm(&["install", "--frozen"]).unwrap();
output2.assert_success();
let installed_path = project.project_path().join(".claude/agents/model-agent.md");
let content = fs::read_to_string(&installed_path).await.unwrap();
assert!(content.contains("model: claude-3-haiku"));
}
#[tokio::test]
async fn test_list_shows_patched_indicator() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
let list_output = project.run_agpm(&["list"]).unwrap();
list_output.assert_success();
assert!(
list_output.stdout.contains("(patched)") || list_output.stdout.contains("patched"),
"List output should indicate resource is patched:\n{}",
list_output.stdout
);
assert!(
list_output.stdout.contains("my-agent") || list_output.stdout.contains("model-agent"),
"List output should show agent name:\n{}",
list_output.stdout
);
}
#[tokio::test]
async fn test_patch_json_file() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_json_mcp(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[mcp-servers]
my-server = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.mcp-servers.my-server]
timeout = 300
retries = 3
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
let mcp_json_path = project.project_path().join(".mcp.json");
if mcp_json_path.exists() {
let content = fs::read_to_string(&mcp_json_path).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
if let Some(mcp_servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
if let Some(server) = mcp_servers.get("test-server") {
assert_eq!(
server.get("timeout").and_then(|v| v.as_i64()),
Some(300),
"JSON patch should update timeout field"
);
assert_eq!(
server.get("retries").and_then(|v| v.as_i64()),
Some(3),
"JSON patch should add retries field"
);
}
}
}
let lockfile_content = project.read_lockfile().await.unwrap();
assert!(
lockfile_content.contains("applied_patches"),
"Lockfile should track patches for JSON resources"
);
}
#[tokio::test]
async fn test_patch_multiple_fields() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
temperature = "0.9"
max_tokens = 4000
system_prompt = "You are a helpful assistant"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
let installed_path = project.project_path().join(".claude/agents/model-agent.md");
let content = fs::read_to_string(&installed_path).await.unwrap();
assert!(content.contains("model: claude-3-haiku"), "Model should be patched");
assert!(content.contains("0.9"), "Temperature should be patched");
assert!(content.contains("4000"), "max_tokens should be patched");
assert!(
content.contains("system_prompt") && content.contains("helpful assistant"),
"New field should be added"
);
let lockfile_content = project.read_lockfile().await.unwrap();
let lockfile: toml::Value = toml::from_str(&lockfile_content).unwrap();
let agents = lockfile.get("agents").and_then(|v| v.as_array()).unwrap();
let patches = agents[0].get("applied_patches").expect("applied_patches should exist");
assert_eq!(patches.as_table().unwrap().len(), 4, "All 4 patches should be tracked");
}
#[tokio::test]
async fn test_patch_lockfile_uses_forward_slashes() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("cross-platform").await.unwrap();
repo.add_resource("agents", "model-agent", "---\nmodel: gpt-4\ntemp: 0.5\n---\n# Agent\n")
.await
.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "agents/model-agent.md", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
temperature = "0.8"
max_tokens = "4096"
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let lockfile_path = project.project_path().join("agpm.lock");
let lockfile_raw = tokio::fs::read_to_string(&lockfile_path).await.unwrap();
assert!(
!lockfile_raw.contains('\\'),
"Lockfile must use forward slashes only. Found backslash in:\n{}",
lockfile_raw
);
assert!(
lockfile_raw.contains("applied_patches")
&& (lockfile_raw.contains("applied_patches =")
|| lockfile_raw.contains("[agents.applied_patches]")),
"Lockfile should have applied_patches field (either inline or as table). Lockfile:\n{}",
lockfile_raw
);
assert!(
lockfile_raw.contains("installed_at = \".claude/agents/"),
"installed_at path should use forward slashes"
);
if lockfile_raw.contains("applied_patches") {
let applied_section_start = lockfile_raw.find("applied_patches").unwrap();
let after_applied = &lockfile_raw[applied_section_start..];
let applied_section_end = after_applied.find("\n\n").unwrap_or(after_applied.len());
let applied_section = &after_applied[..applied_section_end];
assert!(
!applied_section.contains('\\'),
"applied_patches section must not contain backslashes:\n{}",
applied_section
);
}
let lockfile = project.read_lockfile().await.unwrap();
let agents_count = lockfile.matches("[[agents]]").count();
assert_eq!(agents_count, 1, "Should have one agent in lockfile");
}
#[tokio::test]
#[cfg(target_os = "windows")]
async fn test_patch_nested_paths_windows() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("windows-nested").await.unwrap();
repo.add_resource(
"agents/category/subcategory",
"deep",
"---\nmodel: opus\n---\n# Deep Agent\n",
)
.await
.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
nested = {{ source = "test", path = "agents/category/subcategory/deep.md", version = "v1.0.0", flatten = false }}
[patch.agents.nested]
model = "haiku"
category = "deeply/nested/path"
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let lockfile_raw =
tokio::fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();
assert!(
lockfile_raw.contains(".claude/agents/category/subcategory/deep.md"),
"Nested path must use forward slashes"
);
assert!(
lockfile_raw.contains("deeply/nested/path")
|| lockfile_raw.contains("\"deeply/nested/path\""),
"Patch values with paths must use forward slashes"
);
let backslash_count = lockfile_raw.matches('\\').count();
assert_eq!(backslash_count, 0, "Found {} backslash(es) in Windows lockfile", backslash_count);
}
#[tokio::test]
async fn test_patch_applies_to_all_pattern_matched_resources() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("pattern-patch").await.unwrap();
for (name, model) in
[("helper-alpha", "gpt-4"), ("helper-beta", "gpt-4"), ("helper-gamma", "claude-3-opus")]
{
let content = format!("---\nmodel: {}\ntemp: 0.7\nrole: helper\n---\n# {}\n", model, name);
repo.add_resource("agents/helpers", name, &content).await.unwrap();
}
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
all-helpers = {{ source = "test", path = "agents/helpers/*.md", version = "v1.0.0", flatten = false }}
[patch.agents.all-helpers]
model = "claude-3-haiku"
max_tokens = "4096"
category = "utility"
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
for name in ["helper-alpha", "helper-beta", "helper-gamma"] {
let agent_path = project.project_path().join(format!(".claude/agents/helpers/{}.md", name));
assert!(agent_path.exists(), "Agent {} should exist", name);
let content = tokio::fs::read_to_string(&agent_path).await.unwrap();
assert!(
content.contains("model: claude-3-haiku"),
"Agent {} should have patched model, got:\n{}",
name,
content
);
assert!(
content.contains("max_tokens: \"4096\"")
|| content.contains("max_tokens: 4096")
|| content.contains("max_tokens: '4096'"),
"Agent {} should have max_tokens patch, got:\n{}",
name,
content
);
assert!(content.contains("category: utility"), "Agent {} should have category patch", name);
assert!(content.contains("temp: 0.7"), "Agent {} should preserve original temp", name);
assert!(content.contains("role: helper"), "Agent {} should preserve original role", name);
}
let lockfile = project.read_lockfile().await.unwrap();
let applied_count = lockfile.matches("applied_patches").count();
assert!(
applied_count >= 3,
"Lockfile should have at least 3 resources with applied_patches, found {}",
applied_count
);
for name in ["helper-alpha", "helper-beta", "helper-gamma"] {
assert!(
lockfile.contains(&format!("name = \"{}\"", name)),
"Lockfile should have entry for {}",
name
);
}
let list_output = project.run_agpm(&["list"]).unwrap();
let patched_count = list_output.stdout.matches("(patched)").count();
assert_eq!(patched_count, 3, "All 3 agents should show as patched in list");
}
#[tokio::test]
async fn test_patch_with_recursive_glob_pattern() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("recursive-pattern").await.unwrap();
repo.add_resource("agents/ai/language", "gpt", "---\nmodel: gpt-4\n---\n# GPT\n")
.await
.unwrap();
repo.add_resource("agents/ai/vision", "dalle", "---\nmodel: dall-e-3\n---\n# DALL-E\n")
.await
.unwrap();
repo.add_resource("agents/code/rust", "rustacean", "---\nmodel: sonnet\n---\n# Rust Helper\n")
.await
.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
ai-agents = {{ source = "test", path = "agents/ai/**/*.md", version = "v1.0.0", flatten = false }}
code-helper = {{ source = "test", path = "agents/code/rust/rustacean.md", version = "v1.0.0", flatten = false }}
[patch.agents.ai-agents]
category = "ai-assistant"
team = "ai"
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let gpt_content =
tokio::fs::read_to_string(project.project_path().join(".claude/agents/ai/language/gpt.md"))
.await
.unwrap();
assert!(gpt_content.contains("category: ai-assistant"), "GPT should have category patch");
assert!(gpt_content.contains("team: ai"), "GPT should have team patch");
let dalle_content =
tokio::fs::read_to_string(project.project_path().join(".claude/agents/ai/vision/dalle.md"))
.await
.unwrap();
assert!(dalle_content.contains("category: ai-assistant"), "DALL-E should have category patch");
let code_content = tokio::fs::read_to_string(
project.project_path().join(".claude/agents/code/rust/rustacean.md"),
)
.await
.unwrap();
assert!(
!code_content.contains("category: ai-assistant"),
"Rust helper should NOT have AI category"
);
assert!(!code_content.contains("team: ai"), "Rust helper should NOT have team patch");
}
#[tokio::test]
async fn test_pattern_patch_with_no_matches() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("no-match").await.unwrap();
repo.add_resource("agents", "single", "---\nmodel: gpt-4\n---\n# Single\n").await.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
nonexistent = {{ source = "test", path = "agents/helpers/*.md", version = "v1.0.0" }}
[patch.agents.nonexistent]
model = "haiku"
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
let _result = project.run_agpm(&["install"]);
}
#[tokio::test]
async fn test_update_preserves_and_reapplies_patches() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("update-patches").await.unwrap();
repo.add_resource(
"agents",
"evolving",
"---\nmodel: gpt-4\ntemp: 0.5\nfeature_a: true\n---\n# V1 Content\nOriginal body.\n",
)
.await
.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
repo.add_resource(
"agents",
"evolving",
"---\nmodel: gpt-4\ntemp: 0.7\nfeature_a: true\nfeature_b: true\n---\n# V2 Content\nUpdated body.\n",
)
.await
.unwrap();
repo.commit_all("v2.0.0").unwrap();
repo.tag_version("v2.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest_v1 = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "agents/evolving.md", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
custom_field = "patched-value"
"#,
url
);
project.write_manifest(&manifest_v1).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let v1_content =
tokio::fs::read_to_string(project.project_path().join(".claude/agents/evolving.md"))
.await
.unwrap();
assert!(v1_content.contains("model: claude-3-haiku"), "v1 should have patched model");
assert!(v1_content.contains("temp: 0.5"), "v1 should have original temp");
assert!(v1_content.contains("custom_field: patched-value"), "v1 should have custom patch");
assert!(v1_content.contains("# V1 Content"), "v1 should have v1 body");
let manifest_v2 = manifest_v1.replace("v1.0.0", "v2.0.0");
project.write_manifest(&manifest_v2).await.unwrap();
project.run_agpm(&["update"]).unwrap().assert_success();
let v2_content =
tokio::fs::read_to_string(project.project_path().join(".claude/agents/evolving.md"))
.await
.unwrap();
assert!(
v2_content.contains("model: claude-3-haiku"),
"Patch should persist after update to v2"
);
assert!(
v2_content.contains("custom_field: patched-value"),
"Custom patch field should persist after update"
);
assert!(v2_content.contains("temp: 0.7"), "v2 temp should be updated from upstream");
assert!(v2_content.contains("feature_b: true"), "v2 new feature should appear");
assert!(v2_content.contains("# V2 Content"), "v2 body should be updated");
assert!(v2_content.contains("Updated body"), "v2 body text should be new");
assert!(!v2_content.contains("Original body"), "v1 body text should be gone");
let lockfile = project.read_lockfile().await.unwrap();
assert!(lockfile.contains("version = \"v2.0.0\""), "Lockfile should show v2.0.0");
assert!(lockfile.contains("applied_patches"), "Lockfile should still have applied_patches");
}
#[tokio::test]
async fn test_update_with_changing_patches() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("patch-change").await.unwrap();
repo.add_resource("agents", "agent", "---\nmodel: gpt-4\ntemp: 0.5\n---\n# Agent\n")
.await
.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
repo.add_resource("agents", "agent", "---\nmodel: claude-3-opus\ntemp: 0.7\n---\n# Updated\n")
.await
.unwrap();
repo.commit_all("v2.0.0").unwrap();
repo.tag_version("v2.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest_v1 = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "agents/agent.md", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
field_a = "value_a"
"#,
url
);
project.write_manifest(&manifest_v1).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let manifest_v2 = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "agents/agent.md", version = "v2.0.0" }}
[patch.agents.my-agent]
model = "claude-3-sonnet"
field_b = "value_b"
"#,
url
);
project.write_manifest(&manifest_v2).await.unwrap();
project.run_agpm(&["update"]).unwrap().assert_success();
let content = tokio::fs::read_to_string(project.project_path().join(".claude/agents/agent.md"))
.await
.unwrap();
assert!(content.contains("model: claude-3-sonnet"), "Updated patch should apply");
assert!(content.contains("field_b: value_b"), "New patch field should appear");
assert!(!content.contains("field_a: value_a"), "Old patch field should be removed");
assert!(content.contains("temp: 0.7"), "Upstream temp change should appear");
}
#[tokio::test]
async fn test_update_removes_patches_when_manifest_patch_removed() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("remove-patch").await.unwrap();
repo.add_resource("agents", "agent", "---\nmodel: gpt-4\n---\n# Agent\n").await.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest_patched = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "agents/agent.md", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
custom_field = "custom_value"
"#,
url
);
project.write_manifest(&manifest_patched).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let patched = tokio::fs::read_to_string(project.project_path().join(".claude/agents/agent.md"))
.await
.unwrap();
assert!(patched.contains("model: claude-3-haiku"), "Should have patched model");
assert!(patched.contains("custom_field: custom_value"), "Should have custom field");
let manifest_no_patch = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "agents/agent.md", version = "v1.0.0" }}
"#,
url
);
project.write_manifest(&manifest_no_patch).await.unwrap();
project.run_agpm(&["update"]).unwrap().assert_success();
let unpatched =
tokio::fs::read_to_string(project.project_path().join(".claude/agents/agent.md"))
.await
.unwrap();
assert!(
unpatched.contains("model: gpt-4"),
"Should revert to upstream model when patch removed"
);
assert!(
!unpatched.contains("custom_field"),
"Custom field should be removed when patch removed"
);
let lockfile = project.read_lockfile().await.unwrap();
let lockfile_toml: toml::Value = toml::from_str(&lockfile).unwrap();
let agents = lockfile_toml.get("agents").and_then(|v| v.as_array()).unwrap();
if let Some(patches) = agents[0].get("applied_patches") {
assert!(
patches.as_table().unwrap().is_empty(),
"applied_patches should be empty when no patches"
);
}
}
#[tokio::test]
async fn test_patch_display_extracts_original_values() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("display-test").await.unwrap();
let agent_content = r#"---
model: claude-3-opus
temperature: "0.5"
max_tokens: 4096
custom_field: "original_value"
---
# Test Agent
This agent has several fields that will be patched.
"#;
repo.add_resource("agents", "display-agent", agent_content).await.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "agents/display-agent.md", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
temperature = "0.8"
max_tokens = 8192
custom_field = "patched_value"
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let list_output = project.run_agpm(&["list", "--detailed"]).unwrap();
list_output.assert_success();
let output_text = list_output.stdout.clone();
assert!(
output_text.contains("model:")
&& output_text.contains("claude-3-opus")
&& output_text.contains("claude-3-haiku"),
"Should show original model (claude-3-opus) → patched model (claude-3-haiku). Output:\n{}",
output_text
);
assert!(
output_text.contains("temperature:")
&& output_text.contains("0.5")
&& output_text.contains("0.8"),
"Should show original temperature (0.5) → patched temperature (0.8). Output:\n{}",
output_text
);
assert!(
output_text.contains("max_tokens:")
&& output_text.contains("4096")
&& output_text.contains("8192"),
"Should show original max_tokens (4096) → patched max_tokens (8192). Output:\n{}",
output_text
);
assert!(
output_text.contains("custom_field:")
&& output_text.contains("original_value")
&& output_text.contains("patched_value"),
"Should show original custom_field (original_value) → patched custom_field (patched_value). Output:\n{}",
output_text
);
assert!(
!output_text.contains("(none)"),
"Should NOT show '(none)' for original values when source file exists. Output:\n{}",
output_text
);
assert!(
output_text.contains(" -") && output_text.contains(" +"),
"Should use diff format with - and + markers for patch display. Output:\n{}",
output_text
);
}
#[tokio::test]
async fn test_patch_display_diff_format() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("format-test").await.unwrap();
let long_description = "a".repeat(100);
let agent_content = format!(
r#"---
model: claude-3-opus
description: "{}"
short_field: "brief"
---
# Format Test Agent
Testing diff format for all patch values.
"#,
long_description
);
repo.add_resource("agents", "format-agent", &agent_content).await.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let long_patch_value = "b".repeat(100);
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
format-agent = {{ source = "test", path = "agents/format-agent.md", version = "v1.0.0" }}
[patch.agents.format-agent]
model = "claude-3-haiku"
description = "{}"
short_field = "tiny"
"#,
url, long_patch_value
);
project.write_manifest(&manifest).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let list_output = project.run_agpm(&["list", "--detailed"]).unwrap();
list_output.assert_success();
let output = list_output.stdout.clone();
assert!(
output.contains("model:") && output.contains(" -") && output.contains(" +"),
"Model patch should use diff format. Output:\n{}",
output
);
assert!(
output.contains("claude-3-opus") && output.contains("claude-3-haiku"),
"Model patch should show both original and patched values. Output:\n{}",
output
);
assert!(
output.contains("short_field:") && output.contains(" -") && output.contains(" +"),
"Short field should also use diff format. Output:\n{}",
output
);
assert!(
output.contains("brief") && output.contains("tiny"),
"Short field should show both original and patched values. Output:\n{}",
output
);
assert!(
output.contains("description:") && output.contains(" -") && output.contains(" +"),
"Long field should use diff format. Output:\n{}",
output
);
}
#[tokio::test]
async fn test_patch_display_in_tree_command() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("tree-test").await.unwrap();
let agent_content = r#"---
model: gpt-4
temperature: "0.7"
---
# Tree Test Agent
Test patch display in tree command.
"#;
repo.add_resource("agents", "tree-agent", agent_content).await.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
tree-agent = {{ source = "test", path = "agents/tree-agent.md", version = "v1.0.0" }}
[patch.agents.tree-agent]
model = "claude-3-sonnet"
temperature = "0.9"
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let tree_output = project.run_agpm(&["tree", "--detailed"]).unwrap();
tree_output.assert_success();
let output = tree_output.stdout.clone();
assert!(
output.contains("model:") && output.contains("gpt-4") && output.contains("claude-3-sonnet"),
"Tree command should show original → patched for model. Output:\n{}",
output
);
assert!(
output.contains("temperature:") && output.contains("0.7") && output.contains("0.9"),
"Tree command should show original → patched for temperature. Output:\n{}",
output
);
assert!(
!output.contains("(none)"),
"Tree command should NOT show '(none)' for original values. Output:\n{}",
output
);
}
#[tokio::test]
async fn test_patch_display_json_original_values() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let repo = project.create_source_repo("json-display").await.unwrap();
let commands_dir = repo.path.join("commands");
tokio::fs::create_dir_all(&commands_dir).await.unwrap();
let command_content = r#"{
"name": "test-command",
"description": "A test command",
"timeout": 30,
"retries": 5,
"enabled": false
}"#;
let command_file = commands_dir.join("test-command.json");
tokio::fs::write(&command_file, command_content).await.unwrap();
repo.commit_all("v1.0.0").unwrap();
repo.tag_version("v1.0.0").unwrap();
let url = repo.bare_file_url(project.sources_path()).unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[commands]
my-command = {{ source = "test", path = "commands/test-command.json", version = "v1.0.0" }}
[patch.commands.my-command]
timeout = 300
retries = 10
enabled = true
priority = 5
"#,
url
);
project.write_manifest(&manifest).await.unwrap();
project.run_agpm(&["install"]).unwrap().assert_success();
let command_path = project.project_path().join(".claude/commands/test-command.json");
assert!(command_path.exists(), "Command file should exist");
let content = tokio::fs::read_to_string(&command_path).await.unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
json.get("timeout").and_then(|v| v.as_i64()),
Some(300),
"Timeout should be patched"
);
assert_eq!(json.get("retries").and_then(|v| v.as_i64()), Some(10), "Retries should be patched");
assert_eq!(
json.get("enabled").and_then(|v| v.as_bool()),
Some(true),
"Enabled should be patched"
);
assert_eq!(json.get("priority").and_then(|v| v.as_i64()), Some(5), "Priority should be added");
let lockfile_content = project.read_lockfile().await.unwrap();
assert!(
lockfile_content.contains("applied_patches"),
"Lockfile should track patches for JSON resources"
);
}
#[tokio::test]
async fn test_validate_check_lock_with_patches() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
temperature = "0.7"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let private_manifest = r#"[patch.agents.my-agent]
max_tokens = 4000
"#;
let private_path = project.project_path().join("agpm.private.toml");
fs::write(&private_path, private_manifest).await.unwrap();
let output = project.run_agpm(&["install"]).unwrap();
output.assert_success();
let output = project.run_agpm(&["validate", "--check-lock"]).unwrap();
assert!(
output.success,
"validate --check-lock should succeed with patched resources. Stderr:\n{}",
output.stderr
);
assert!(
output.stdout.contains("✓") || output.stdout.contains("valid"),
"Validate should report success for patched resources. Output:\n{}",
output.stdout
);
let lockfile_content = project.read_lockfile().await.unwrap();
assert!(lockfile_content.contains("applied_patches"), "Lockfile should track applied patches");
}
#[tokio::test]
async fn test_validate_resolve_with_patches() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.my-agent]
model = "claude-3-haiku"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["validate", "--resolve"]).unwrap();
assert!(
output.success,
"validate --resolve should succeed with patches. Stderr:\n{}",
output.stderr
);
}
#[tokio::test]
async fn test_validate_detects_unknown_patch_alias() {
test_config::init_test_env();
let project = TestProject::new().await.unwrap();
let (url, path) = create_repo_with_model_agent(&project).await.unwrap();
let manifest = format!(
r#"[sources]
test = "{}"
[agents]
my-agent = {{ source = "test", path = "{}", version = "v1.0.0" }}
[patch.agents.nonexistent-agent]
model = "claude-3-haiku"
"#,
url, path
);
project.write_manifest(&manifest).await.unwrap();
let output = project.run_agpm(&["validate"]).unwrap();
assert!(!output.success, "validate should fail when patch references unknown alias");
assert!(
output.stderr.contains("nonexistent-agent") || output.stdout.contains("nonexistent-agent"),
"Error should mention the unknown alias in patch section. Output:\nstdout: {}\nstderr: {}",
output.stdout,
output.stderr
);
}