feat(TASK-047): three-state hook install mode, top-level manifest reconcile, removed-agent sweep

- Thread an explicit InstallMode (Skip/Prompt/Force) through the hook
  installers: startup install_builtins() is pinned to Skip and never
  prompts (test-pinned on both mocked-TTY and TTY-less paths);
  --install-builtins hooks runs Prompt mode across all three hook
  locations (global hooks dir, roles/hooks, agents/<name>/hooks) under
  one shared keep-all/replace-all sticky scope; force paths keep
  unconditional overwrite semantics.
- Hoist the bundle resolve_conflict/StickyMode/ConflictAction prompt
  into shared config::conflict with a NonInteractive policy: bundle
  installs keep their non-TTY bail, builtin hook refreshes resolve
  non-TTY conflicts to keep. Bundle UX preserved byte-for-byte,
  pinned by tests.
- Extend .builtin-manifest reconcile to the resolved top-level hooks
  dir (shipped set from HookAssets::iter()); malformed-manifest
  fail-safe (zero deletions) preserved and test-pinned.
- Sweep removed builtin agents' hooks: agent dirs absent from the
  embed with a hooks/.builtin-manifest reconcile against an empty
  shipped set; empty hooks dirs are removed, user files and
  manifest-less agent dirs are never touched.
- Update usage-probe-hooks.sh Scenario C to the new non-interactive
  keep contract and add black-box Scenarios E-G covering role/agent
  hook reconcile, top-level manifest fail-safety, and the
  removed-agent sweep.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 17:59:09 -06:00
1 parent d89d24aff4
commit e7bd75947c
8 files changed
+1280 -120

No files matched your search

+39 -24
View File
@@ -5497,6 +5497,7 @@ mod tests {
use crate::config::AppState;
use crate::config::agent::AgentConfig;
use crate::config::bundles::BundleStore;
use crate::config::conflict::InstallMode;
use crate::config::mcp_tool_policy::LayerSource;
use crate::config::tool_scope::test_fixtures::{FixtureServer, fixture_runtime};
use crate::function::jobs::RingBuf;
@@ -9184,19 +9185,19 @@ mod tests {
fn install_builtin_agents_force_overwrites_only_with_force() {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
let file =
first_file(&paths::agents_data_dir()).expect("bundled agents should be installed");
write(&file, "SENTINEL").unwrap();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
assert_eq!(
read_to_string(&file).unwrap(),
"SENTINEL",
"non-force install must not overwrite an existing file"
);
Agent::install_builtin_agents(true).unwrap();
Agent::install_builtin_agents(InstallMode::Force).unwrap();
assert_ne!(
read_to_string(&file).unwrap(),
"SENTINEL",
@@ -9209,7 +9210,7 @@ mod tests {
fn install_builtin_agents_removes_stale_definition_files() {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
let agents_dir = paths::agents_data_dir();
// Simulate an upgrade from an older install: coder used to ship
@@ -9230,7 +9231,7 @@ mod tests {
create_dir_all(agents_dir.join("myagent")).unwrap();
write(agents_dir.join("myagent").join("config.yaml"), "keep").unwrap();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
assert!(
!agents_dir.join("coder").join("config.yaml").exists(),
@@ -9270,12 +9271,12 @@ mod tests {
fn install_builtin_agents_removes_stale_graph_for_config_agent() {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
let agents_dir = paths::agents_data_dir();
write(agents_dir.join("architect").join("graph.yaml"), "stale").unwrap();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
assert!(
!agents_dir.join("architect").join("graph.yaml").exists(),
@@ -9291,7 +9292,7 @@ mod tests {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
let agents_dir = paths::agents_data_dir();
// Simulate an upgrade from an older install whose coder shipped a
@@ -9303,14 +9304,20 @@ mod tests {
write(hooks_dir.join(BUILTIN_MANIFEST_FILE), "old-hook.sh\n").unwrap();
write(hooks_dir.join("old-hook.sh"), "stale").unwrap();
write(hooks_dir.join("user-hook.sh"), "user-owned").unwrap();
// A custom agent outside the bundle is never reconciled, even with a
// manifest present.
let custom_hooks = agents_dir.join("myagent").join("hooks");
create_dir_all(&custom_hooks).unwrap();
write(custom_hooks.join(BUILTIN_MANIFEST_FILE), "custom.sh\n").unwrap();
write(custom_hooks.join("custom.sh"), "keep").unwrap();
// An agent directory carrying a builtin manifest but absent from the
// embed is a removed builtin agent: the sweep deletes exactly the
// manifest-owned files, sparing anything user-created alongside.
let removed_hooks = agents_dir.join("myagent").join("hooks");
create_dir_all(&removed_hooks).unwrap();
write(removed_hooks.join(BUILTIN_MANIFEST_FILE), "shipped.sh\n").unwrap();
write(removed_hooks.join("shipped.sh"), "stale").unwrap();
write(removed_hooks.join("user-note.sh"), "keep").unwrap();
// A user agent never has a manifest, so the sweep must not touch it.
let user_hooks = agents_dir.join("useragent").join("hooks");
create_dir_all(&user_hooks).unwrap();
write(user_hooks.join("mine.sh"), "keep").unwrap();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
assert!(
!hooks_dir.join("old-hook.sh").exists(),
@@ -9325,10 +9332,18 @@ mod tests {
"an empty shipped set removes the manifest itself"
);
assert!(
custom_hooks.join("custom.sh").exists(),
"custom agents outside the bundle must survive reconciliation"
!removed_hooks.join("shipped.sh").exists(),
"manifest-owned hooks of a removed builtin agent must be swept"
);
assert!(!removed_hooks.join(BUILTIN_MANIFEST_FILE).exists());
assert!(
removed_hooks.join("user-note.sh").exists(),
"user files beside a removed agent's manifest must survive the sweep"
);
assert!(
user_hooks.join("mine.sh").exists(),
"agent dirs without a manifest must never be touched"
);
assert!(custom_hooks.join(BUILTIN_MANIFEST_FILE).exists());
}
#[test]
@@ -9372,7 +9387,7 @@ mod tests {
#[serial]
fn bundled_assets_pin_task_queue_guidance() {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
// 1. Architect config: the parallel-mode "Task-queue mirroring"
// section with all five HARD RULES.
@@ -9441,7 +9456,7 @@ mod tests {
#[serial]
fn bundled_assets_pin_review_incomplete_escalation() {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
// Every anchor is a single-line prefix so YAML re-wrapping cannot
// break the pin; clauses that wrap differently per config are split
@@ -9605,7 +9620,7 @@ mod tests {
#[serial]
fn bundled_assets_pin_verification_commands_as_declared_variable() {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
const VARIABLES_FORM: &str =
"--variables {\"verification_commands\": \"[\\\"cargo test --all\\\"";
@@ -9796,7 +9811,7 @@ mod tests {
}
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
for (name, expected) in [
("architect", 10),
@@ -9915,7 +9930,7 @@ mod tests {
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
Skill::install_builtin_skills(false).unwrap();
let mut checked = Vec::new();
@@ -10142,7 +10157,7 @@ mod tests {
let engine_seeded = ["initial_prompt", "output", "choice", "input"];
let _guard = TestConfigDirGuard::new();
Agent::install_builtin_agents(false).unwrap();
Agent::install_builtin_agents(InstallMode::Skip).unwrap();
let mut checked = Vec::new();
for entry in std::fs::read_dir(paths::agents_data_dir()).unwrap() {