use super::append_mode_reminder;
use super::*;
use crate::agent::model_family::resolve_family;
use crate::agent::tools::ToolCache;
use crate::cli::Cli;
use crate::config::Config;
use crate::sandbox::{Sandbox, SandboxMode};
use clap::Parser;
#[test]
fn plan_mode_injects_plan_reminder() {
let mut p = String::from("base");
append_mode_reminder(&mut p, "plan", false);
assert!(p.contains("PLAN mode"));
assert!(p.contains("PLAN.md"));
assert!(p.contains("Do NOT write any code"));
}
#[test]
fn review_modes_inject_review_reminder() {
for mode in &["review", "review-security"] {
let mut p = String::from("base");
append_mode_reminder(&mut p, mode, false);
assert!(p.contains("REVIEW mode"), "mode={mode}");
assert!(p.contains("Identify bugs"), "mode={mode}");
}
}
#[test]
fn deepseek_chat_gets_steering_fragment() {
let family = resolve_family("deepseek", "deepseek-v4-pro");
let frag = model_steering_fragment(family).expect("deepseek chat should get steering");
assert!(
frag.contains("Plan-Execute-Verify"),
"fragment should carry the research-backed guidance"
);
assert!(
frag.contains("re-issue the same call"),
"fragment should carry the anti-repetition rule"
);
}
#[test]
fn other_models_get_no_steering_fragment() {
assert!(model_steering_fragment(resolve_family("openai", "gpt-4o")).is_none());
assert!(model_steering_fragment(resolve_family("anthropic", "claude-sonnet-4-6")).is_none());
}
#[test]
fn deepseek_reasoner_gets_no_steering_fragment() {
let family = resolve_family("deepseek", "deepseek-reasoner");
assert!(model_steering_fragment(family).is_none());
}
#[test]
fn regression_code_mode_reminder_requires_plan_md() {
let mut p_with = String::from("base");
append_mode_reminder(&mut p_with, "code", true);
assert!(p_with.contains("plan file exists"));
let mut p_without = String::from("base");
append_mode_reminder(&mut p_without, "code", false);
assert_eq!(p_without, "base", "no reminder must be added");
}
#[test]
fn unknown_prompt_name_appends_nothing() {
let mut p = String::from("base");
append_mode_reminder(&mut p, "my-custom-prompt", true);
assert_eq!(p, "base");
}
#[test]
fn reminders_use_section_separator() {
let mut p = String::new();
append_mode_reminder(&mut p, "plan", false);
assert!(p.starts_with("\n\n---\n\n"), "got: {p:?}");
}
#[test]
fn mcp_collision_filter_covers_plan_tools() {
let expected_builtins = [
"read",
"write",
"edit",
"bash",
"grep",
"find_files",
"glob",
"list_dir",
"write_todo_list",
"apply_patch",
"memory",
"skill",
"task",
"task_status",
"question",
"webfetch",
"websearch",
"lsp",
"plan_enter",
"plan_exit",
];
for name in expected_builtins {
assert!(!name.is_empty());
}
}
#[tokio::test]
async fn build_loop_tools_produces_core_registry() {
let cli = Cli::parse_from::<_, &str>(["dirge"]);
let cfg = Config::default();
let cache = ToolCache::new();
let sandbox = Sandbox::new(SandboxMode::Off);
let (tools, _, _) = build_loop_tools(
cache,
None, None, None, None, None, #[cfg(feature = "lsp")]
None,
sandbox,
None, #[cfg(feature = "mcp")]
None,
#[cfg(feature = "semantic")]
None,
&cli,
&cfg,
None, )
.await;
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
for expected in ["read", "write", "edit", "bash", "grep", "list_dir"] {
assert!(
names.contains(&expected),
"missing built-in {expected} in {names:?}"
);
}
}
#[tokio::test]
async fn memory_tool_registration_degrades_when_store_unavailable() {
use crate::agent::builder::loop_tools::register_memory_tool;
let mut tools: Vec<std::sync::Arc<dyn crate::agent::agent_loop::LoopTool>> = Vec::new();
register_memory_tool(&mut tools, None, None, None, None).await;
assert!(
tools.is_empty(),
"unavailable store must not register a memory tool"
);
let dir = std::env::temp_dir().join(format!(
"dirge-memtool-degrade-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join(".git")).unwrap();
let paths = crate::extras::dirge_paths::ProjectPaths::new(&dir);
let store: std::sync::Arc<dyn crate::extras::memory_provider::MemoryProvider> =
std::sync::Arc::new(crate::extras::memory_db::SqliteMemoryStore::load(&paths).unwrap());
register_memory_tool(&mut tools, Some(store), None, None, None).await;
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(names, vec!["memory"], "available store registers the tool");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn tool_descriptions_meet_quality_bar() {
let cli = Cli::parse_from::<_, &str>(["dirge"]);
let cfg = Config::default();
let (tools, _, _) = build_loop_tools(
ToolCache::new(),
None,
None,
None,
None,
None,
#[cfg(feature = "lsp")]
None,
Sandbox::new(SandboxMode::Off),
None,
#[cfg(feature = "mcp")]
None,
#[cfg(feature = "semantic")]
None,
&cli,
&cfg,
None,
)
.await;
for tool in &tools {
let desc = tool.description();
let len = desc.chars().count();
assert!(
(16..=1024).contains(&len),
"tool `{}` description is {len} chars; must be a substantive 16..=1024 (Forge tool-guidelines): {desc:?}",
tool.name()
);
}
}
#[test]
fn base_preamble_includes_skills_guidance() {
let p = assemble_base_preamble();
assert!(p.contains("complex task"), "missing create trigger");
assert!(p.contains("5+ tool calls"), "missing 5+ trigger");
assert!(
p.contains("patch it immediately"),
"missing patch-now trigger"
);
assert!(p.contains("action='create'"), "missing create action");
assert!(p.contains("action='patch'"), "missing patch action");
assert!(!p.contains("skill_manage"), "leaked hermes tool name");
assert!(
p.contains("## Skill creation and maintenance"),
"missing heading"
);
}
#[test]
fn base_preamble_includes_finishing_selfcheck() {
let p = assemble_base_preamble();
assert!(
p.contains("# Finishing"),
"missing the Finishing section heading"
);
assert!(
p.to_lowercase().contains("self-check"),
"missing the single self-check"
);
assert!(p.contains("exactly what was asked"), "missing scope check");
assert!(
p.to_lowercase().contains("verified"),
"missing verify check"
);
assert!(
p.to_lowercase().contains("unrequested"),
"missing no-scope-creep check"
);
assert!(
p.to_lowercase().contains("stop"),
"missing an explicit stop condition"
);
}
#[test]
fn base_preamble_carries_full_guidance_suite() {
let p = assemble_base_preamble();
assert!(
p.contains("# Finishing a task"),
"F2 finishing self-check missing"
);
assert!(
p.contains("# Progress updates"),
"F3 progress narration missing"
);
assert!(
p.contains("# Clarifying vs. proceeding"),
"F5 ask-vs-proceed calibration missing"
);
}
#[test]
fn base_preamble_includes_progress_updates() {
let p = assemble_base_preamble();
assert!(
p.contains("# Progress updates"),
"missing the Progress updates section heading"
);
assert!(
p.to_lowercase().contains("plan up front"),
"missing the up-front plan guidance"
);
assert!(
p.to_lowercase().contains("multi-step"),
"progress guidance must be scoped to multi-step tasks"
);
assert!(
p.to_lowercase().contains("final reply"),
"must distinguish progress notes from the final reply"
);
}
#[test]
fn base_preamble_includes_ask_calibration() {
let p = assemble_base_preamble();
let lower = p.to_lowercase();
assert!(
p.contains("# Clarifying vs. proceeding"),
"missing the Clarifying-vs-proceeding section"
);
assert!(
lower.contains("hard to reverse") || lower.contains("costly"),
"missing the cost/recoverability signal"
);
assert!(
lower.contains("infer"),
"missing the inferable-from-context signal"
);
assert!(
lower.contains("assumption"),
"missing the state-your-assumption path"
);
}
#[test]
fn memory_preamble_injection_uses_trait_dispatch() {
use crate::extras::memory_provider::MemoryProvider;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct RecordingProvider {
calls: AtomicUsize,
block: String,
}
impl MemoryProvider for RecordingProvider {
fn name(&self) -> &str {
"recording"
}
fn format_for_system_prompt(&self) -> String {
self.calls.fetch_add(1, Ordering::SeqCst);
self.block.clone()
}
fn view(&self, _: &str) -> serde_json::Value {
serde_json::Value::Null
}
fn add(&self, _: &str, _: &str, _kind: Option<&str>) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn replace(
&self,
_: &str,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn remove(&self, _: &str, _: &str) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
}
let provider: Arc<dyn MemoryProvider> = Arc::new(RecordingProvider {
calls: AtomicUsize::new(0),
block: "\n\n## RecordingProviderBlock\n\nplugin-supplied prompt text\n".into(),
});
let mut preamble = String::from("base");
append_memory_to_preamble(&mut preamble, &provider);
assert!(
preamble.contains("## RecordingProviderBlock"),
"plugin provider's prompt heading must appear: {preamble}"
);
assert!(
preamble.contains("plugin-supplied prompt text"),
"plugin provider's body must appear: {preamble}"
);
let empty: Arc<dyn MemoryProvider> = Arc::new(RecordingProvider {
calls: AtomicUsize::new(0),
block: String::new(),
});
let mut preamble2 = String::from("base2");
append_memory_to_preamble(&mut preamble2, &empty);
assert_eq!(
preamble2, "base2",
"empty provider block must not append anything"
);
}
#[tokio::test]
async fn build_agent_inner_emits_assembled_preamble() {
use crate::context::ContextFiles;
use rig::client::CompletionClient;
use rig::providers::openai;
let cli = Cli::parse_from::<_, &str>(["dirge"]);
let cfg = Config::default();
let context = ContextFiles {
agents: None,
prompts: std::collections::HashMap::new(),
agent_defs: Default::default(),
current_agent: None,
current_prompt: None,
current_prompt_name: None,
current_prompt_deny_tools: Vec::new(),
prompt_layer: None,
agent_layer: None,
model_before_agent: None,
};
let client = openai::Client::new("test-key").expect("openai client builds");
let model = client.completion_model("gpt-4o");
let (agent, _cache, _provider) =
build_agent_inner(model, &cli, &cfg, &context, "openai", "gpt-4o").await;
let preamble = agent.preamble.unwrap_or_default();
assert!(
preamble.contains("## Skill creation and maintenance"),
"preamble must include skills heading"
);
assert!(
preamble.contains("complex task"),
"preamble must include create trigger"
);
assert!(
preamble.contains("action='patch'"),
"preamble must include skill patch action"
);
assert!(
preamble.contains("persistent memory"),
"preamble must include memory intro"
);
assert!(
preamble.contains("Do NOT save task progress"),
"preamble must include do-not-save rule"
);
assert!(
preamble.contains("declarative facts"),
"preamble must include declarative-fact framing"
);
assert!(
preamble.contains("session_search"),
"preamble must mention session_search"
);
assert!(
preamble.contains("before asking them to repeat"),
"preamble must include past-session-recall nudge"
);
for action in ["view", "add", "replace", "remove"] {
assert!(
preamble.contains(action),
"preamble must mention real memory action '{}'",
action
);
}
for forbidden in ["delete", "create"] {
let mem_line = preamble
.lines()
.find(|l| l.trim_start().starts_with("- memory:"))
.expect("memory bullet present");
assert!(
!mem_line
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|w| w == forbidden),
"memory bullet must not name forbidden action '{}': {}",
forbidden,
mem_line
);
}
if preamble.contains("## Project Skills") {
assert!(
preamble.contains("action='load'"),
"project-skills preamble must direct to action='load'"
);
}
}
#[tokio::test]
async fn preamble_lists_global_tier_skills() {
use crate::context::ContextFiles;
use rig::client::CompletionClient;
use rig::providers::openai;
use std::sync::Mutex;
static HOME_LOCK: Mutex<()> = Mutex::new(());
let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("dirge-preamble-home-{}", std::process::id()));
let skill_dir = home
.join(".dirge")
.join("skills")
.join("global-preamble-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: global-preamble-skill\ndescription: advertised from the global tier\n---\nBody.\n",
)
.unwrap();
let cli = Cli::parse_from::<_, &str>(["dirge"]);
let cfg = Config::default();
let context = ContextFiles {
agents: None,
prompts: std::collections::HashMap::new(),
agent_defs: Default::default(),
current_agent: None,
current_prompt: None,
current_prompt_name: None,
current_prompt_deny_tools: Vec::new(),
prompt_layer: None,
agent_layer: None,
model_before_agent: None,
};
let client = openai::Client::new("test-key").expect("openai client builds");
let model = client.completion_model("gpt-4o");
let prev_home = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &home) };
let (agent, _cache, _provider) =
build_agent_inner(model, &cli, &cfg, &context, "openai", "gpt-4o").await;
match prev_home {
Some(h) => unsafe { std::env::set_var("HOME", h) },
None => unsafe { std::env::remove_var("HOME") },
}
let preamble = agent.preamble.unwrap_or_default();
assert!(
preamble.contains("global-preamble-skill"),
"preamble must advertise a skill from the global ~/.dirge/skills tier; got:\n{preamble}"
);
let _ = std::fs::remove_dir_all(&home);
}
#[tokio::test]
async fn steering_fragment_tracks_active_model_not_cli() {
use crate::context::ContextFiles;
use rig::client::CompletionClient;
use rig::providers::openai;
let cli = Cli::parse_from::<_, &str>(["dirge"]); let cfg = Config::default();
let empty_ctx = || ContextFiles {
agents: None,
prompts: std::collections::HashMap::new(),
agent_defs: Default::default(),
current_agent: None,
current_prompt: None,
current_prompt_name: None,
current_prompt_deny_tools: Vec::new(),
prompt_layer: None,
agent_layer: None,
model_before_agent: None,
};
let client = openai::Client::new("test-key").expect("openai client builds");
let ctx = empty_ctx();
let model = client.completion_model("gpt-4o");
let (agent, _c, _p) =
build_agent_inner(model, &cli, &cfg, &ctx, "deepseek", "deepseek-v4-pro").await;
assert!(
agent
.preamble
.unwrap_or_default()
.contains("Plan-Execute-Verify"),
"DeepSeek-chat active model must inject the steering fragment"
);
let ctx = empty_ctx();
let model = client.completion_model("gpt-4o");
let (agent, _c, _p) = build_agent_inner(model, &cli, &cfg, &ctx, "openai", "gpt-4o").await;
assert!(
!agent
.preamble
.unwrap_or_default()
.contains("Plan-Execute-Verify"),
"non-DeepSeek active model must NOT inject the steering fragment"
);
}
#[test]
fn base_preamble_includes_memory_and_search_guidance() {
let p = assemble_base_preamble();
assert!(p.contains("persistent memory"), "missing memory-tool intro");
assert!(
p.contains("Do NOT save task progress"),
"missing do-not-save rule"
);
assert!(
p.contains("PR numbers"),
"missing example list of stale artifacts"
);
assert!(
p.contains("declarative facts"),
"missing declarative-fact framing"
);
assert!(
p.contains("Procedures and workflows belong in skills"),
"missing memory-vs-skills boundary"
);
assert!(
p.contains("session_search"),
"missing session_search tool name"
);
assert!(
p.contains("before asking them to repeat"),
"missing past-session-recall nudge"
);
}
#[test]
fn build_session_search_tool_threads_session_id() {
let db_path = std::path::PathBuf::from("/tmp/dirge-502b-test.db");
let tool = build_session_search_tool(db_path.clone(), Some("sess-test-id".into()), None, None);
assert_eq!(
tool.current_session_id(),
Some("sess-test-id"),
"factory must thread session_id into SessionSearchTool"
);
let tool_none = build_session_search_tool(db_path, None, None, None);
assert!(
tool_none.current_session_id().is_none(),
"factory must not invent a session id when called with None"
);
}
#[tokio::test]
async fn build_loop_tools_empty_with_no_tools() {
let cli = Cli::parse_from::<_, &str>(["dirge", "--no-tools"]);
let cfg = Config::default();
let cache = ToolCache::new();
let sandbox = Sandbox::new(SandboxMode::Off);
let (tools, _, _) = build_loop_tools(
cache,
None,
None,
None,
None,
None,
#[cfg(feature = "lsp")]
None,
sandbox,
None,
#[cfg(feature = "mcp")]
None,
#[cfg(feature = "semantic")]
None,
&cli,
&cfg,
None, )
.await;
assert!(tools.is_empty(), "--no-tools should yield empty registry");
}
#[tokio::test]
async fn build_loop_tools_mutating_tools_are_sequential() {
use crate::agent::agent_loop::types::ToolExecutionMode;
let cli = Cli::parse_from::<_, &str>(["dirge"]);
let cfg = Config::default();
let cache = ToolCache::new();
let sandbox = Sandbox::new(SandboxMode::Off);
let (tools, _, _) = build_loop_tools(
cache,
None,
None,
None,
None,
None,
#[cfg(feature = "lsp")]
None,
sandbox,
None,
#[cfg(feature = "mcp")]
None,
#[cfg(feature = "semantic")]
None,
&cli,
&cfg,
None, )
.await;
for mutating in ["write", "edit", "bash", "apply_patch"] {
let tool = tools
.iter()
.find(|t| t.name() == mutating)
.unwrap_or_else(|| panic!("{mutating} missing from registry"));
assert_eq!(
tool.execution_mode(),
Some(ToolExecutionMode::Sequential),
"{mutating} should be Sequential",
);
}
}
#[tokio::test]
async fn build_loop_tools_read_only_tools_are_parallel_capable() {
let cli = Cli::parse_from::<_, &str>(["dirge"]);
let cfg = Config::default();
let cache = ToolCache::new();
let sandbox = Sandbox::new(SandboxMode::Off);
let (tools, _, _) = build_loop_tools(
cache,
None,
None,
None,
None,
None,
#[cfg(feature = "lsp")]
None,
sandbox,
None,
#[cfg(feature = "mcp")]
None,
#[cfg(feature = "semantic")]
None,
&cli,
&cfg,
None, )
.await;
for read_only in ["read", "grep", "list_dir", "find_files"] {
let tool = tools
.iter()
.find(|t| t.name() == read_only)
.unwrap_or_else(|| panic!("{read_only} missing from registry"));
assert!(
tool.execution_mode().is_none(),
"{read_only} should leave execution_mode at None (Parallel-capable)",
);
}
}