#![allow(unsafe_code)]
#![allow(clippy::large_futures)]
#![recursion_limit = "256"]
use std::collections::VecDeque;
use std::path::Path;
use std::sync::{Arc, Mutex};
use zeph_core::agent::Agent;
use zeph_core::channel::{Channel, ChannelError, ChannelMessage};
use zeph_core::config::{Config, ProviderKind, SecurityConfig, TimeoutConfig};
use zeph_llm::any::AnyProvider;
use zeph_llm::mock::MockProvider;
use zeph_llm::provider::{ChatResponse, ToolUseRequest};
use zeph_memory::semantic::SemanticMemory;
use zeph_memory::store::SqliteStore;
use zeph_skills::loader::load_skill;
use zeph_skills::registry::SkillRegistry;
use zeph_tools::AutonomyLevel;
use zeph_tools::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
fn mock(response: &str) -> AnyProvider {
let mut p = MockProvider::default();
p.default_response = response.to_string();
AnyProvider::Mock(p)
}
fn tool_use_provider(final_text: &str) -> AnyProvider {
let tool_call = ToolUseRequest {
id: "call1".into(),
name: "bash".into(),
input: serde_json::json!({}),
};
let (p, _) = MockProvider::default().with_tool_use(vec![
ChatResponse::ToolUse {
text: None,
tool_calls: vec![tool_call],
thinking_blocks: vec![],
},
ChatResponse::Text(final_text.to_string()),
]);
AnyProvider::Mock(p)
}
fn multi_tool_use_provider(iterations: usize, final_text: &str) -> AnyProvider {
let mut responses = Vec::new();
for i in 0..iterations {
responses.push(ChatResponse::ToolUse {
text: None,
tool_calls: vec![ToolUseRequest {
id: format!("call{i}"),
name: "bash".into(),
input: serde_json::json!({}),
}],
thinking_blocks: vec![],
});
}
responses.push(ChatResponse::Text(final_text.to_string()));
let (p, _) = MockProvider::default().with_tool_use(responses);
AnyProvider::Mock(p)
}
fn streaming_mock(response: &str) -> AnyProvider {
let mut p = MockProvider::default().with_streaming();
p.default_response = response.to_string();
AnyProvider::Mock(p)
}
fn empty_provider() -> AnyProvider {
let mut p = MockProvider::default();
p.default_response = String::new();
AnyProvider::Mock(p)
}
fn failing_provider() -> AnyProvider {
AnyProvider::Mock(MockProvider::failing())
}
fn slow_provider(delay_ms: u64) -> AnyProvider {
AnyProvider::Mock(MockProvider::default().with_delay(delay_ms))
}
fn slow_streaming_provider(delay_ms: u64) -> AnyProvider {
AnyProvider::Mock(
MockProvider::default()
.with_delay(delay_ms)
.with_streaming(),
)
}
#[derive(Debug)]
struct MockChannel {
inputs: VecDeque<String>,
outputs: Arc<Mutex<Vec<String>>>,
}
impl MockChannel {
fn new(inputs: Vec<&str>, outputs: Arc<Mutex<Vec<String>>>) -> Self {
Self {
inputs: inputs.into_iter().map(String::from).collect(),
outputs,
}
}
}
impl Channel for MockChannel {
async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
Ok(self.inputs.pop_front().map(|text| ChannelMessage {
text,
attachments: vec![],
is_guest_context: false,
is_from_bot: false,
}))
}
async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
self.outputs.lock().unwrap().push(text.to_string());
Ok(())
}
async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
Ok(())
}
async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
Ok(())
}
}
struct ConfirmMockChannel {
inputs: VecDeque<String>,
outputs: Arc<Mutex<Vec<String>>>,
confirm_result: bool,
confirm_called: Arc<Mutex<bool>>,
}
impl Channel for ConfirmMockChannel {
async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
Ok(self.inputs.pop_front().map(|text| ChannelMessage {
text,
attachments: vec![],
is_guest_context: false,
is_from_bot: false,
}))
}
async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
self.outputs.lock().unwrap().push(text.to_string());
Ok(())
}
async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
Ok(())
}
async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
Ok(())
}
async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
*self.confirm_called.lock().unwrap() = true;
Ok(self.confirm_result)
}
}
struct MockToolExecutor;
impl ToolExecutor for MockToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(None)
}
}
struct OutputToolExecutor {
output: String,
}
impl ToolExecutor for OutputToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash"),
summary: self.output.clone(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: call.tool_id.clone(),
summary: self.output.clone(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
struct EmptyOutputToolExecutor;
impl ToolExecutor for EmptyOutputToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash"),
summary: String::new(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: call.tool_id.clone(),
summary: String::new(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
struct ErrorOutputToolExecutor;
impl ToolExecutor for ErrorOutputToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash"),
summary: "[error] command failed".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: call.tool_id.clone(),
summary: "[error] command failed".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
struct BlockedToolExecutor;
impl ToolExecutor for BlockedToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::Blocked {
command: "rm -rf /".into(),
})
}
async fn execute_tool_call(&self, _call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::Blocked {
command: "rm -rf /".into(),
})
}
}
struct ConfirmToolExecutor;
impl ToolExecutor for ConfirmToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::ConfirmationRequired {
command: "rm -rf /tmp".into(),
})
}
async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash"),
summary: "confirmed output".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
async fn execute_tool_call(&self, _call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::ConfirmationRequired {
command: "rm -rf /tmp".into(),
})
}
async fn execute_tool_call_confirmed(
&self,
call: &ToolCall,
) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: call.tool_id.clone(),
summary: "confirmed output".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
struct SandboxToolExecutor;
impl ToolExecutor for SandboxToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::SandboxViolation {
path: "/etc/passwd".into(),
})
}
async fn execute_tool_call(&self, _call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::SandboxViolation {
path: "/etc/passwd".into(),
})
}
}
struct IoErrorToolExecutor;
impl ToolExecutor for IoErrorToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::Execution(std::io::Error::new(
std::io::ErrorKind::NotFound,
"command not found",
)))
}
async fn execute_tool_call(&self, _call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Err(ToolError::Execution(std::io::Error::new(
std::io::ErrorKind::NotFound,
"command not found",
)))
}
}
struct ExitCodeToolExecutor;
impl ToolExecutor for ExitCodeToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash"),
summary: "[exit code 1] process failed".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: call.tool_id.clone(),
summary: "[exit code 1] process failed".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
const ENV_KEYS: [&str; 5] = [
"ZEPH_LLM_PROVIDER",
"ZEPH_LLM_BASE_URL",
"ZEPH_LLM_MODEL",
"ZEPH_SQLITE_PATH",
"ZEPH_TELEGRAM_TOKEN",
];
fn clear_env() {
for key in ENV_KEYS {
unsafe { std::env::remove_var(key) };
}
}
#[test]
fn config_defaults_and_env_overrides() {
clear_env();
let config = Config::load(Path::new("/nonexistent/config.toml")).unwrap();
assert_eq!(config.llm.effective_provider(), ProviderKind::Ollama);
assert_eq!(config.llm.effective_base_url(), "http://localhost:11434");
assert_eq!(config.llm.effective_model(), "qwen3:8b");
assert_eq!(config.agent.name, "Zeph");
assert_eq!(config.memory.history_limit, 50);
unsafe { std::env::set_var("ZEPH_LLM_MODEL", "test-model") };
let config = Config::load(Path::new("/nonexistent/config.toml")).unwrap();
unsafe { std::env::remove_var("ZEPH_LLM_MODEL") };
assert_eq!(config.llm.effective_model(), "test-model");
}
#[test]
fn skill_parse_valid() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
let path = skill_dir.join("SKILL.md");
std::fs::write(
&path,
"---\nname: test-skill\ndescription: A test.\n---\n# Instructions\nDo stuff.",
)
.unwrap();
let skill = load_skill(&path).unwrap();
assert_eq!(skill.name(), "test-skill");
assert_eq!(skill.description(), "A test.");
assert!(skill.body.contains("Do stuff."));
}
#[test]
fn skill_parse_invalid_no_frontmatter() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SKILL.md");
std::fs::write(&path, "no frontmatter here").unwrap();
assert!(load_skill(&path).is_err());
}
#[test]
fn skill_registry_scans_temp_dir() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("alpha");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: alpha\ndescription: first\n---\nbody",
)
.unwrap();
let skill_dir2 = dir.path().join("beta");
std::fs::create_dir(&skill_dir2).unwrap();
std::fs::write(
skill_dir2.join("SKILL.md"),
"---\nname: beta\ndescription: second\n---\nbody",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
assert_eq!(registry.all_meta().len(), 2);
}
#[tokio::test]
async fn memory_save_load_roundtrip() {
let store = SqliteStore::new(":memory:").await.unwrap();
let cid = store.create_conversation().await.unwrap();
store.save_message(cid, "user", "hello").await.unwrap();
store.save_message(cid, "assistant", "world").await.unwrap();
let history = store.load_history(cid, 50).await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].content, "hello");
assert_eq!(history[1].content, "world");
}
#[tokio::test]
async fn memory_history_limit() {
let store = SqliteStore::new(":memory:").await.unwrap();
let cid = store.create_conversation().await.unwrap();
for i in 0..20 {
store
.save_message(cid, "user", &format!("msg {i}"))
.await
.unwrap();
}
let history = store.load_history(cid, 5).await.unwrap();
assert_eq!(history.len(), 5);
}
#[tokio::test]
async fn memory_conversation_isolation() {
let store = SqliteStore::new(":memory:").await.unwrap();
let cid1 = store.create_conversation().await.unwrap();
let cid2 = store.create_conversation().await.unwrap();
store.save_message(cid1, "user", "conv1").await.unwrap();
store.save_message(cid2, "user", "conv2").await.unwrap();
let h1 = store.load_history(cid1, 50).await.unwrap();
let h2 = store.load_history(cid2, 50).await.unwrap();
assert_eq!(h1.len(), 1);
assert_eq!(h1[0].content, "conv1");
assert_eq!(h2.len(), 1);
assert_eq!(h2[0].content, "conv2");
}
#[tokio::test]
async fn agent_roundtrip_mock() {
let provider = mock("mock response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert_eq!(collected[0], "mock response");
}
#[tokio::test]
async fn agent_multiple_messages() {
let provider = mock("reply");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["first", "second", "third"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 3);
assert!(collected.iter().all(|o| o == "reply"));
}
#[tokio::test]
async fn agent_with_memory() {
let provider = mock("remembered");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["save this"], outputs.clone());
let executor = MockToolExecutor;
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334", None,
provider.clone(),
"test-model",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
}
#[tokio::test]
async fn agent_shutdown_via_watch() {
let provider = mock("should not appear");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec![], outputs.clone());
let executor = MockToolExecutor;
let (tx, rx) = tokio::sync::watch::channel(false);
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_shutdown(rx);
let _ = tx.send(true);
agent.run().await.unwrap();
}
#[tokio::test]
async fn agent_builder_with_embedding_model() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["test"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_embedding_model("custom-model".into());
agent.run().await.unwrap();
}
#[tokio::test]
async fn agent_load_history_with_memory() {
let provider = mock("reply");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec![], outputs.clone());
let executor = MockToolExecutor;
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
memory
.sqlite()
.save_message(cid, "user", "hello")
.await
.unwrap();
memory
.sqlite()
.save_message(cid, "assistant", "world")
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.load_history().await.unwrap();
}
#[tokio::test]
async fn agent_load_history_without_memory() {
let provider = mock("reply");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec![], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.load_history().await.unwrap();
}
#[tokio::test]
async fn agent_skills_command() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skills"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(!collected.is_empty());
}
#[tokio::test]
async fn agent_skill_activate_command() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill activate test-skill"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
}
#[tokio::test]
async fn agent_skill_deactivate_command() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill deactivate test-skill"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
}
#[tokio::test]
async fn agent_with_bash_tool_executor() {
let provider = mock("```bash\necho hello\n```");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["run command"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
}
#[tokio::test]
async fn agent_process_response_with_tool_output() {
let tool_call = ToolUseRequest {
id: "call1".into(),
name: "bash".into(),
input: serde_json::json!({}),
};
let (p, _) = MockProvider::default().with_tool_use(vec![
ChatResponse::ToolUse {
text: None,
tool_calls: vec![tool_call.clone()],
thinking_blocks: vec![],
},
ChatResponse::ToolUse {
text: None,
tool_calls: vec![tool_call.clone()],
thinking_blocks: vec![],
},
ChatResponse::Text("done".to_string()),
]);
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["do something"], outputs.clone());
let executor = OutputToolExecutor {
output: "command output".into(),
};
let mut agent = Agent::new(
AnyProvider::Mock(p),
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("command output")));
}
#[tokio::test]
async fn agent_process_response_tool_loop_max_iterations() {
let tool_call = ToolUseRequest {
id: "call1".into(),
name: "bash".into(),
input: serde_json::json!({}),
};
let (p, _) = MockProvider::default().with_tool_use(vec![
ChatResponse::ToolUse {
text: None,
tool_calls: vec![tool_call.clone()],
thinking_blocks: vec![],
},
ChatResponse::ToolUse {
text: None,
tool_calls: vec![tool_call.clone()],
thinking_blocks: vec![],
},
ChatResponse::ToolUse {
text: None,
tool_calls: vec![tool_call],
thinking_blocks: vec![],
},
ChatResponse::Text("done".to_string()),
]);
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["start"], outputs.clone());
let executor = OutputToolExecutor {
output: "tool result".into(),
};
let mut agent = Agent::new(
AnyProvider::Mock(p),
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("tool result")));
}
#[tokio::test]
async fn agent_non_streaming_provider() {
let provider = mock("non-stream response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert_eq!(collected[0], "non-stream response");
}
#[tokio::test]
async fn agent_streaming_provider() {
let provider = streaming_mock("streamed");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert_eq!(collected[0], "streamed");
}
#[tokio::test]
async fn agent_tool_output_empty_summary() {
let provider = mock("response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = EmptyOutputToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
}
#[tokio::test]
async fn agent_tool_output_with_error_marker() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["do it"], outputs.clone());
let executor = ErrorOutputToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_tool_output = collected.iter().any(|o| o.contains("[error]"));
assert!(has_tool_output);
}
#[tokio::test]
async fn agent_tool_output_with_exit_code_marker() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["do it"], outputs.clone());
let executor = ExitCodeToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_exit_code = collected.iter().any(|o| o.contains("[exit code"));
assert!(has_exit_code);
}
#[tokio::test]
async fn agent_tool_blocked_command() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = BlockedToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_blocked = collected
.iter()
.any(|o| o.contains("blocked by security policy"));
assert!(has_blocked);
}
#[tokio::test]
async fn agent_tool_confirmation_required_approved() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let confirm_called = Arc::new(Mutex::new(false));
let channel = ConfirmMockChannel {
inputs: vec!["go".to_string()].into_iter().collect(),
outputs: outputs.clone(),
confirm_result: true,
confirm_called: confirm_called.clone(),
};
let executor = ConfirmToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
assert!(*confirm_called.lock().unwrap());
let collected = outputs.lock().unwrap();
let has_confirmed_output = collected.iter().any(|o| o.contains("confirmed output"));
assert!(has_confirmed_output);
}
#[tokio::test]
async fn agent_tool_confirmation_required_denied() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let confirm_called = Arc::new(Mutex::new(false));
let channel = ConfirmMockChannel {
inputs: vec!["go".to_string()].into_iter().collect(),
outputs: outputs.clone(),
confirm_result: false,
confirm_called: confirm_called.clone(),
};
let executor = ConfirmToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
assert!(*confirm_called.lock().unwrap());
let collected = outputs.lock().unwrap();
let has_cancelled = collected.iter().any(|o| o.contains("cancelled"));
assert!(has_cancelled);
}
#[tokio::test]
async fn agent_tool_sandbox_violation() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = SandboxToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_sandbox = collected.iter().any(|o| o.contains("sandbox"));
assert!(has_sandbox);
}
#[tokio::test]
async fn agent_tool_generic_error() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = IoErrorToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_failure = collected
.iter()
.any(|o| o.contains("tool_error") || o.contains("Tool execution failed"));
assert!(has_failure);
}
#[tokio::test]
async fn agent_empty_response_handling() {
let provider = empty_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.is_empty());
}
#[tokio::test]
async fn agent_provider_error_handling() {
let provider = failing_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_error = collected.iter().any(|o| o.contains("Error:"));
assert!(has_error, "expected error message, got: {collected:?}");
}
#[tokio::test]
async fn agent_streaming_response_accumulates_chunks() {
let provider = streaming_mock("abc");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["test"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert_eq!(collected[0], "abc");
}
#[tokio::test]
async fn agent_redaction_enabled() {
let provider = mock("use key sk-abc123def456 for auth");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["show key"], outputs.clone());
let executor = MockToolExecutor;
let security = SecurityConfig {
redact_secrets: true,
autonomy_level: AutonomyLevel::default(),
..Default::default()
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_security(security, TimeoutConfig::default());
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert!(collected[0].contains("[REDACTED]"));
assert!(!collected[0].contains("sk-abc123def456"));
}
#[tokio::test]
async fn agent_redaction_disabled() {
let provider = mock("use key sk-abc123def456 for auth");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["show key"], outputs.clone());
let executor = MockToolExecutor;
let security = SecurityConfig {
redact_secrets: false,
autonomy_level: AutonomyLevel::default(),
..Default::default()
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_security(security, TimeoutConfig::default());
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert!(collected[0].contains("sk-abc123def456"));
}
#[tokio::test]
async fn agent_persist_message_with_memory() {
let provider = mock("stored response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["save me"], outputs.clone());
let executor = MockToolExecutor;
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let store = SqliteStore::new(":memory:").await.unwrap();
let _ = store;
}
#[tokio::test]
async fn agent_check_summarization_triggers() {
let provider = mock("reply");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["msg1", "msg2", "msg3"], outputs.clone());
let executor = MockToolExecutor;
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 2);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 3);
}
#[tokio::test]
async fn agent_skills_command_with_usage_stats() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello", "/skills"], outputs.clone());
let executor = MockToolExecutor;
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: test-skill\ndescription: A test skill.\n---\nbody",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
100,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let skills_output = collected.iter().find(|o| o.contains("Available skills"));
assert!(skills_output.is_some());
}
#[tokio::test]
async fn agent_skill_command_disabled() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill stats"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(!collected.is_empty());
}
#[tokio::test]
async fn agent_feedback_disabled() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill bad output"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(!collected.is_empty());
}
#[tokio::test]
async fn agent_with_security_config() {
let provider = mock("response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let security = SecurityConfig {
redact_secrets: true,
autonomy_level: AutonomyLevel::default(),
..Default::default()
};
let timeouts = TimeoutConfig {
llm_seconds: 60,
embedding_seconds: 15,
a2a_seconds: 10,
max_parallel_tools: 8,
..TimeoutConfig::default()
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_security(security, timeouts);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
}
#[tokio::test]
async fn agent_with_skill_reload() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["test"], outputs.clone());
let executor = MockToolExecutor;
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let dir = tempfile::tempdir().unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
}
#[tokio::test]
async fn agent_skill_reload_via_channel() {
use zeph_skills::watcher::SkillEvent;
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["after reload"], outputs.clone());
let executor = MockToolExecutor;
let (tx, rx) = tokio::sync::mpsc::channel(16);
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("reload-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: reload-skill\ndescription: reload test\n---\nbody",
)
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
tx.send(SkillEvent::Changed).await.unwrap();
drop(tx);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(!collected.is_empty());
}
#[tokio::test]
async fn agent_rebuild_without_matcher() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["query"], outputs.clone());
let executor = MockToolExecutor;
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("my-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: A skill.\n---\nInstructions here.",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
}
#[tokio::test]
async fn agent_llm_timeout_non_streaming() {
let provider = slow_provider(10_000);
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let timeouts = TimeoutConfig {
llm_seconds: 1,
..TimeoutConfig::default()
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_security(SecurityConfig::default(), timeouts);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_timeout = collected.iter().any(|o| o.contains("timed out"));
assert!(has_timeout);
}
#[tokio::test]
async fn agent_llm_timeout_streaming() {
let provider = slow_streaming_provider(10_000);
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let timeouts = TimeoutConfig {
llm_seconds: 1,
..TimeoutConfig::default()
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_security(SecurityConfig::default(), timeouts);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_timeout = collected.iter().any(|o| o.contains("timed out"));
assert!(has_timeout);
}
#[tokio::test]
async fn agent_streaming_redaction() {
let provider = streaming_mock("key sk-secret123");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["show secret"], outputs.clone());
let executor = MockToolExecutor;
let security = SecurityConfig {
redact_secrets: true,
autonomy_level: AutonomyLevel::default(),
..Default::default()
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_security(security, TimeoutConfig::default());
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert!(collected[0].contains("[REDACTED]"));
assert!(!collected[0].contains("sk-secret123"));
}
#[tokio::test]
async fn agent_redaction_in_tool_output() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = OutputToolExecutor {
output: "found key sk-abc123secret in config".into(),
};
let security = SecurityConfig {
redact_secrets: true,
autonomy_level: AutonomyLevel::default(),
..Default::default()
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_security(security, TimeoutConfig::default());
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let has_redacted = collected.iter().any(|o| o.contains("[REDACTED]"));
assert!(has_redacted);
let has_secret = collected.iter().any(|o| o.contains("sk-abc123secret"));
assert!(!has_secret);
}
#[tokio::test]
async fn agent_persist_messages_verified() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("response-123");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["user-input"], outputs.clone());
let executor = MockToolExecutor;
let memory = SemanticMemory::new(
db_str,
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(
provider.clone(),
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let history = store.load_history(cid, 50).await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].content, "user-input");
assert_eq!(history[1].content, "response-123");
}
#[tokio::test]
async fn agent_load_history_skips_empty_messages() {
let provider = mock("reply");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec![], outputs.clone());
let executor = MockToolExecutor;
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
memory
.sqlite()
.save_message(cid, "user", "valid")
.await
.unwrap();
memory
.sqlite()
.save_message(cid, "assistant", " ")
.await
.unwrap();
memory
.sqlite()
.save_message(cid, "user", "also valid")
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.load_history().await.unwrap();
}
#[tokio::test]
async fn agent_persist_multiple_exchanges() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("ack");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["msg1", "msg2"], outputs.clone());
let executor = MockToolExecutor;
let memory = SemanticMemory::new(
db_str,
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(
provider.clone(),
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let history = store.load_history(cid, 50).await.unwrap();
assert_eq!(history.len(), 4); }
#[tokio::test]
async fn agent_tool_output_persisted_in_memory() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = OutputToolExecutor {
output: "tool result text".into(),
};
let memory = SemanticMemory::new(
db_str,
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(
provider.clone(),
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let history = store.load_history(cid, 50).await.unwrap();
let has_tool_msg = history
.iter()
.any(|m| m.content.contains("[tool_result") || m.content.contains("[tool output"));
assert!(has_tool_msg);
}
#[tokio::test]
async fn agent_shutdown_during_processing() {
let provider = mock("response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec![], outputs.clone());
let executor = MockToolExecutor;
let (tx, rx) = tokio::sync::watch::channel(false);
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_shutdown(rx);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let _ = tx.send(true);
});
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.is_empty());
}
#[tokio::test]
async fn agent_confirmation_approved_with_output_persisted() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let confirm_called = Arc::new(Mutex::new(false));
let channel = ConfirmMockChannel {
inputs: vec!["go".to_string()].into_iter().collect(),
outputs: outputs.clone(),
confirm_result: true,
confirm_called: confirm_called.clone(),
};
let executor = ConfirmToolExecutor;
let memory = SemanticMemory::new(
db_str,
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(
provider.clone(),
channel,
SkillRegistry::default(),
None,
5,
executor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let history = store.load_history(cid, 50).await.unwrap();
let has_confirmed = history
.iter()
.any(|m| m.content.contains("confirmed output"));
assert!(has_confirmed);
}
#[tokio::test]
async fn agent_skills_command_with_loaded_skills() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skills"], outputs.clone());
let executor = MockToolExecutor;
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("alpha");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: alpha\ndescription: first skill\n---\nbody",
)
.unwrap();
let skill_dir2 = dir.path().join("beta");
std::fs::create_dir(&skill_dir2).unwrap();
std::fs::write(
skill_dir2.join("SKILL.md"),
"---\nname: beta\ndescription: second skill\n---\nbody",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert!(collected[0].contains("Available skills"));
assert!(collected[0].contains("alpha"));
assert!(collected[0].contains("beta"));
}
#[tokio::test]
async fn agent_skill_unknown_subcommand() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill unknown-cmd"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(!collected.is_empty());
}
#[tokio::test]
async fn agent_feedback_without_args() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(!collected.is_empty());
}
#[tokio::test]
async fn agent_rebuild_with_skill_matcher() {
use zeph_skills::matcher::{SkillMatcher, SkillMatcherBackend};
let provider = mock("matched response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["query about alpha"], outputs.clone());
let executor = MockToolExecutor;
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("alpha");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: alpha\ndescription: first skill\n---\nalpha body",
)
.unwrap();
let skill_dir2 = dir.path().join("beta");
std::fs::create_dir(&skill_dir2).unwrap();
std::fs::write(
skill_dir2.join("SKILL.md"),
"---\nname: beta\ndescription: second skill\n---\nbeta body",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let all_meta = registry.all_meta();
let embed_fn = |text: &str| -> zeph_skills::matcher::EmbedFuture {
let _ = text;
Box::pin(async { Ok(vec![1.0, 0.0, 0.0]) })
};
let matcher = SkillMatcher::new(&all_meta, embed_fn).await;
let backend = matcher.map(SkillMatcherBackend::InMemory);
let mut agent = Agent::new(provider, channel, registry, backend, 1, executor);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert_eq!(collected[0], "matched response");
}
#[tokio::test]
async fn agent_disambiguation_reorders_skill_selection() {
use zeph_skills::matcher::{SkillMatcher, SkillMatcherBackend};
let disambiguation_json =
r#"{"skill_name":"second-skill","confidence":0.9,"params":{}}"#.to_string();
let mut provider_inner =
MockProvider::with_responses(vec![disambiguation_json, "ok".to_string()]);
provider_inner.supports_embeddings = true;
provider_inner.embedding = vec![1.0, 0.0];
let provider = AnyProvider::Mock(provider_inner);
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["do the second thing"], outputs.clone());
let dir = tempfile::tempdir().unwrap();
let skill_dir1 = dir.path().join("first-skill");
std::fs::create_dir(&skill_dir1).unwrap();
std::fs::write(
skill_dir1.join("SKILL.md"),
"---\nname: first-skill\ndescription: first skill\n---\nfirst body",
)
.unwrap();
let skill_dir2 = dir.path().join("second-skill");
std::fs::create_dir(&skill_dir2).unwrap();
std::fs::write(
skill_dir2.join("SKILL.md"),
"---\nname: second-skill\ndescription: second skill\n---\nsecond body",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let all_meta = registry.all_meta();
assert_eq!(all_meta.len(), 2, "both skills must be loaded");
let embed_fn = |text: &str| -> zeph_skills::matcher::EmbedFuture {
let _ = text;
Box::pin(async { Ok(vec![1.0_f32, 0.0]) })
};
let matcher = SkillMatcher::new(&all_meta, embed_fn)
.await
.expect("matcher must be built: embed_fn always succeeds");
let backend = SkillMatcherBackend::InMemory(matcher);
let (tx, rx) = tokio::sync::watch::channel(zeph_core::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(
provider,
channel,
registry,
Some(backend),
2,
MockToolExecutor,
)
.with_skill_matching_config(1.0, false, 0.0)
.with_metrics(tx);
agent.run().await.unwrap();
let snapshot = rx.borrow().clone();
assert!(
!snapshot.active_skills.is_empty(),
"active_skills must be populated after run"
);
assert_eq!(
snapshot.active_skills[0], "second-skill",
"disambiguation must move second-skill to the front"
);
}
#[tokio::test]
async fn agent_mixed_commands_and_messages() {
let provider = mock("reply");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(
vec!["/skills", "normal message", "/skill stats"],
outputs.clone(),
);
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 3);
}
#[tokio::test]
async fn agent_tool_loop_three_iterations() {
let provider = multi_tool_use_provider(3, "done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["start"], outputs.clone());
let executor = OutputToolExecutor {
output: "hello".into(),
};
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.len() >= 3);
}
#[tokio::test]
async fn agent_records_skill_usage() {
use zeph_skills::matcher::{SkillMatcher, SkillMatcherBackend};
let tmpdir = tempfile::tempdir().unwrap();
let db_path = tmpdir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let mut provider_inner = MockProvider::default();
provider_inner.default_response = "ok".to_string();
provider_inner.supports_embeddings = true;
provider_inner.embedding = vec![1.0_f32];
let provider = AnyProvider::Mock(provider_inner);
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("tracked-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: tracked-skill\ndescription: tracked\n---\nbody",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let all_meta = registry.all_meta();
let embed_fn = |text: &str| -> zeph_skills::matcher::EmbedFuture {
let _ = text;
Box::pin(async { Ok(vec![1.0_f32]) })
};
let matcher = SkillMatcher::new(&all_meta, embed_fn)
.await
.expect("matcher must build");
let backend = Some(SkillMatcherBackend::InMemory(matcher));
let memory = SemanticMemory::new(
db_str,
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(provider.clone(), channel, registry, backend, 5, executor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let usage = store.load_skill_usage().await.unwrap();
let has_tracked = usage.iter().any(|u| u.skill_name == "tracked-skill");
assert!(has_tracked);
}
#[tokio::test]
async fn agent_no_matcher_skips_skill_usage() {
let tmpdir = tempfile::tempdir().unwrap();
let db_path = tmpdir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("tracked-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: tracked-skill\ndescription: tracked\n---\nbody",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let memory = SemanticMemory::new(
db_str,
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
let mut agent = Agent::new(provider.clone(), channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
100,
);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let usage = store.load_skill_usage().await.unwrap();
assert!(
usage.is_empty(),
"no usage should be recorded when skill matching falls back to the full set"
);
}
#[tokio::test]
async fn agent_streaming_empty_response() {
let provider = streaming_mock("");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.is_empty());
}
#[tokio::test]
async fn agent_blocked_does_not_loop() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = BlockedToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("blocked by security policy"))
);
}
#[tokio::test]
async fn agent_sandbox_violation_does_not_loop() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = SandboxToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("sandbox")));
}
#[tokio::test]
async fn agent_io_error_does_not_loop() {
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = IoErrorToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("tool_error") || o.contains("Tool execution failed"))
);
}
#[tokio::test]
async fn agent_no_tool_output_stops_loop() {
let provider = mock("simple text");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["go"], outputs.clone());
let executor = MockToolExecutor;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
executor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
}
mod self_learning {
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use zeph_core::agent::Agent;
use zeph_core::channel::{Channel, ChannelError, ChannelMessage};
use zeph_core::config::LearningConfig;
use zeph_llm::any::AnyProvider;
use zeph_llm::mock::MockProvider;
use zeph_memory::semantic::SemanticMemory;
use zeph_memory::store::SqliteStore;
use zeph_memory::types::ConversationId;
use zeph_skills::registry::SkillRegistry;
use zeph_tools::executor::{ToolError, ToolExecutor, ToolOutput};
fn mock(response: &str) -> AnyProvider {
let mut p = MockProvider::default();
p.default_response = response.to_string();
AnyProvider::Mock(p)
}
fn tool_use_provider(final_text: &str) -> AnyProvider {
use zeph_llm::provider::{ChatResponse, ToolUseRequest};
let tool_call = ToolUseRequest {
id: "call1".into(),
name: "bash".into(),
input: serde_json::json!({}),
};
let (p, _) = MockProvider::default().with_tool_use(vec![
ChatResponse::ToolUse {
text: None,
tool_calls: vec![tool_call],
thinking_blocks: vec![],
},
ChatResponse::Text(final_text.to_string()),
]);
AnyProvider::Mock(p)
}
#[derive(Debug)]
struct MockChannel {
inputs: VecDeque<String>,
outputs: Arc<Mutex<Vec<String>>>,
}
impl MockChannel {
fn new(inputs: Vec<&str>, outputs: Arc<Mutex<Vec<String>>>) -> Self {
Self {
inputs: inputs.into_iter().map(String::from).collect(),
outputs,
}
}
}
impl Channel for MockChannel {
async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
Ok(self.inputs.pop_front().map(|text| ChannelMessage {
text,
attachments: vec![],
is_guest_context: false,
is_from_bot: false,
}))
}
async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
self.outputs.lock().unwrap().push(text.to_string());
Ok(())
}
async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
Ok(())
}
async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
Ok(())
}
}
struct MockToolExecutor;
impl ToolExecutor for MockToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(None)
}
}
struct ErrorToolExecutor;
impl ToolExecutor for ErrorToolExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash"),
summary: "[error] command failed".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
async fn execute_tool_call(
&self,
call: &zeph_tools::executor::ToolCall,
) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: call.tool_id.clone(),
summary: "[error] command failed".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
async fn make_memory(provider: &AnyProvider) -> (SemanticMemory, ConversationId) {
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
(memory, cid)
}
async fn make_memory_file(
provider: &AnyProvider,
db_path: &str,
) -> (SemanticMemory, ConversationId) {
let memory = SemanticMemory::new(
db_path,
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
(memory, cid)
}
fn learning_config(enabled: bool) -> LearningConfig {
LearningConfig {
enabled,
auto_activate: false,
min_failures: 3,
improve_threshold: 0.7,
rollback_threshold: 0.5,
min_evaluations: 5,
max_versions: 10,
cooldown_minutes: 0,
..Default::default()
}
}
fn make_skill_dir() -> (tempfile::TempDir, SkillRegistry) {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: test-skill\ndescription: A test skill.\n---\nDo test stuff.",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
(dir, registry)
}
#[tokio::test]
async fn skill_stats_no_memory() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill stats"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Memory not available")));
}
#[tokio::test]
async fn skill_stats_empty_data() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill stats"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("No skill outcome data"))
);
}
#[tokio::test]
async fn skill_stats_with_outcomes() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill stats"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
memory
.sqlite()
.record_skill_outcome("git", None, Some(cid), "success", None, None)
.await
.unwrap();
memory
.sqlite()
.record_skill_outcome("git", None, Some(cid), "tool_failure", Some("err"), None)
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let stats_output = collected
.iter()
.find(|o| o.contains("Skill outcome statistics"));
assert!(stats_output.is_some());
assert!(stats_output.unwrap().contains("git"));
}
#[tokio::test]
async fn skill_versions_no_name() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill versions"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Usage:")));
}
#[tokio::test]
async fn skill_versions_no_memory() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill versions git"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Memory not available")));
}
#[tokio::test]
async fn skill_versions_empty() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill versions nonexistent"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("No versions found")));
}
#[tokio::test]
async fn skill_versions_with_data() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill versions git"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let v1 = memory
.sqlite()
.save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
.await
.unwrap();
memory
.sqlite()
.activate_skill_version("git", v1)
.await
.unwrap();
memory
.sqlite()
.save_skill_version("git", 2, "body v2", "Git helper", "auto", None, Some(v1))
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
let versions_output = collected.iter().find(|o| o.contains("Versions for"));
assert!(versions_output.is_some());
let text = versions_output.unwrap();
assert!(text.contains("v1"));
assert!(text.contains("v2"));
assert!(text.contains("active"));
}
#[tokio::test]
async fn skill_activate_missing_args() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill activate"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Usage:")));
}
#[tokio::test]
async fn skill_activate_invalid_version() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill activate git abc"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Invalid version")));
}
#[tokio::test]
async fn skill_activate_no_memory() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill activate git 1"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Memory not available")));
}
#[tokio::test]
async fn skill_activate_version_not_found() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill activate git 99"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("not found")));
}
#[tokio::test]
async fn skill_activate_success() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("git");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: git\ndescription: Git helper\n---\nold body",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill activate git 2"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let v1 = memory
.sqlite()
.save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
.await
.unwrap();
memory
.sqlite()
.activate_skill_version("git", v1)
.await
.unwrap();
memory
.sqlite()
.save_skill_version("git", 2, "body v2", "Git helper v2", "auto", None, Some(v1))
.await
.unwrap();
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Activated v2")));
let content = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
assert!(content.contains("body v2"));
}
#[tokio::test]
async fn skill_approve_no_name() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill approve"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Usage:")));
}
#[tokio::test]
async fn skill_approve_no_memory() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill approve git"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Memory not available")));
}
#[tokio::test]
async fn skill_approve_no_pending() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill approve git"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let v1 = memory
.sqlite()
.save_skill_version("git", 1, "body", "desc", "manual", None, None)
.await
.unwrap();
memory
.sqlite()
.activate_skill_version("git", v1)
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("No pending auto version"))
);
}
#[tokio::test]
async fn skill_approve_success() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("git");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: git\ndescription: Git helper\n---\nold body",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill approve git"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let v1 = memory
.sqlite()
.save_skill_version("git", 1, "body v1", "desc", "manual", None, None)
.await
.unwrap();
memory
.sqlite()
.activate_skill_version("git", v1)
.await
.unwrap();
memory
.sqlite()
.save_skill_version("git", 2, "improved body", "desc", "auto", None, Some(v1))
.await
.unwrap();
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("Approved and activated v2"))
);
let content = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
assert!(content.contains("improved body"));
}
#[tokio::test]
async fn skill_reset_no_name() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill reset"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Usage:")));
}
#[tokio::test]
async fn skill_reset_no_memory() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill reset git"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Memory not available")));
}
#[tokio::test]
async fn skill_reset_no_v1() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill reset git"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("Original version not found"))
);
}
#[tokio::test]
async fn skill_reset_success() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("git");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: git\ndescription: Git helper\n---\nmodified body",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill reset git"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let v1 = memory
.sqlite()
.save_skill_version(
"git",
1,
"original body",
"Git helper",
"manual",
None,
None,
)
.await
.unwrap();
let v2 = memory
.sqlite()
.save_skill_version(
"git",
2,
"modified body",
"Git helper",
"auto",
None,
Some(v1),
)
.await
.unwrap();
memory
.sqlite()
.activate_skill_version("git", v2)
.await
.unwrap();
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("Reset \"git\" to original v1"))
);
let content = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
assert!(content.contains("original body"));
}
#[tokio::test]
async fn skill_unknown_subcommand() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill bogus"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected
.iter()
.any(|o| o.contains("Unknown /skill subcommand"))
);
}
#[tokio::test]
async fn feedback_no_message() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Usage:")));
}
#[tokio::test]
async fn feedback_empty_message() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill \"\""], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Usage:")));
}
#[tokio::test]
async fn feedback_no_memory() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill bad output"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Memory not available")));
}
#[tokio::test]
async fn feedback_records_outcome() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill bad output"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, db_str).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
{
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Feedback recorded")));
}
let store = SqliteStore::new(db_str).await.unwrap();
let stats = store.load_skill_outcome_stats().await.unwrap();
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].skill_name, "test-skill");
}
#[tokio::test]
async fn feedback_with_learning_triggers_improvement() {
let (dir, registry) = make_skill_dir();
let provider = mock("improved skill body content");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(
vec!["/feedback test-skill \"the output is wrong\""],
outputs.clone(),
);
let (memory, cid) = make_memory(&provider).await;
let config = learning_config(true);
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Feedback recorded")));
}
#[tokio::test]
async fn learning_enabled_with_config() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let config = learning_config(true);
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config);
agent.run().await.unwrap();
}
#[tokio::test]
async fn learning_disabled_without_config() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
}
#[tokio::test]
async fn record_skill_outcomes_with_active_skills() {
let (dir, registry) = make_skill_dir();
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = tool_use_provider("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, db_str).await;
let config = learning_config(false);
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let stats = store.load_skill_outcome_stats().await.unwrap();
assert!(stats.iter().any(|s| s.skill_name == "test-skill"));
}
#[tokio::test]
async fn record_skill_outcomes_tool_failure() {
let (dir, registry) = make_skill_dir();
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = tool_use_provider("done");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["do it"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, db_str).await;
let config = learning_config(false);
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, ErrorToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let stats = store.load_skill_outcome_stats().await.unwrap();
let skill_stats = stats.iter().find(|s| s.skill_name == "test-skill");
assert!(skill_stats.is_some());
assert!(skill_stats.unwrap().failures > 0);
}
#[tokio::test]
async fn check_rollback_triggers_on_low_success_rate() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: test-skill\ndescription: A test.\n---\nauto body",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["do it"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, db_str).await;
let v1 = memory
.sqlite()
.save_skill_version("test-skill", 1, "original", "desc", "manual", None, None)
.await
.unwrap();
let v2 = memory
.sqlite()
.save_skill_version("test-skill", 2, "auto body", "desc", "auto", None, Some(v1))
.await
.unwrap();
memory
.sqlite()
.activate_skill_version("test-skill", v2)
.await
.unwrap();
for _ in 0..6 {
memory
.sqlite()
.record_skill_outcome(
"test-skill",
None,
Some(cid),
"tool_failure",
Some("err"),
None,
)
.await
.unwrap();
}
let config = LearningConfig {
enabled: true,
auto_activate: false,
min_failures: 1,
improve_threshold: 0.7,
rollback_threshold: 0.5,
min_evaluations: 5,
max_versions: 10,
cooldown_minutes: 0,
..Default::default()
};
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, ErrorToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let active = store.active_skill_version("test-skill").await.unwrap();
assert!(active.is_some());
assert_eq!(active.unwrap().version, 1);
}
#[tokio::test]
async fn check_rollback_skips_when_not_auto() {
let (dir, registry) = make_skill_dir();
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("response");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["do it"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, db_str).await;
let v1 = memory
.sqlite()
.save_skill_version("test-skill", 1, "original", "desc", "manual", None, None)
.await
.unwrap();
memory
.sqlite()
.activate_skill_version("test-skill", v1)
.await
.unwrap();
for _ in 0..6 {
memory
.sqlite()
.record_skill_outcome("test-skill", None, Some(cid), "tool_failure", None, None)
.await
.unwrap();
}
let config = LearningConfig {
enabled: true,
auto_activate: false,
min_failures: 1,
improve_threshold: 0.7,
rollback_threshold: 0.5,
min_evaluations: 5,
max_versions: 10,
cooldown_minutes: 0,
..Default::default()
};
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, ErrorToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let active = store.active_skill_version("test-skill").await.unwrap();
assert!(active.is_some());
assert_eq!(active.unwrap().version, 1);
}
#[tokio::test]
async fn improvement_blocked_by_min_failures() {
let (dir, registry) = make_skill_dir();
let provider = mock("improved body");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill bad result"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
memory
.sqlite()
.ensure_skill_version_exists("test-skill", "Do test stuff.", "A test skill.")
.await
.unwrap();
memory
.sqlite()
.record_skill_outcome("test-skill", None, Some(cid), "success", None, None)
.await
.unwrap();
let config = LearningConfig {
enabled: true,
auto_activate: false,
min_failures: 100,
improve_threshold: 0.7,
rollback_threshold: 0.5,
min_evaluations: 5,
max_versions: 10,
cooldown_minutes: 0,
..Default::default()
};
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Feedback recorded")));
}
#[tokio::test]
async fn generate_and_auto_activate_improvement() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: test-skill\ndescription: A test skill.\n---\nDo test stuff.",
)
.unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("improved test stuff");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(
vec!["/feedback test-skill \"bad output, that's wrong\""],
outputs.clone(),
);
let (memory, cid) = make_memory_file(&provider, db_str).await;
let config = LearningConfig {
enabled: true,
auto_activate: true,
min_failures: 0,
improve_threshold: 1.0,
rollback_threshold: 0.5,
min_evaluations: 5,
max_versions: 10,
cooldown_minutes: 0,
..Default::default()
};
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let versions = store.load_skill_versions("test-skill").await.unwrap();
assert!(versions.len() >= 2);
let active = store.active_skill_version("test-skill").await.unwrap();
assert!(active.is_some());
assert!(active.unwrap().version >= 2);
}
#[tokio::test]
async fn self_reflection_on_empty_response() {
let (dir, registry) = make_skill_dir();
let provider = AnyProvider::Mock(MockProvider::with_responses(vec![String::new()]));
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, ":memory:").await;
let config = learning_config(true);
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(collected.is_empty());
}
#[tokio::test]
async fn with_learning_builder() {
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["test"], outputs.clone());
let config = learning_config(true);
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_learning(config);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
}
#[tokio::test]
async fn feedback_learning_disabled_no_improvement() {
let (dir, registry) = make_skill_dir();
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/feedback test-skill bad result"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, db_str).await;
let config = learning_config(false);
let (tx, rx) = tokio::sync::mpsc::channel(16);
drop(tx);
let mut agent = Agent::new(provider, channel, registry, None, 5, MockToolExecutor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config)
.with_skill_reload(vec![dir.path().to_path_buf()], rx);
agent.run().await.unwrap();
{
let collected = outputs.lock().unwrap();
assert!(collected.iter().any(|o| o.contains("Feedback recorded")));
}
let store = SqliteStore::new(db_str).await.unwrap();
let versions = store.load_skill_versions("test-skill").await.unwrap();
assert!(versions.is_empty());
}
#[tokio::test]
async fn record_outcomes_no_active_skills() {
let db_dir = tempfile::tempdir().unwrap();
let db_path = db_dir.path().join("test.db");
let db_str = db_path.to_str().unwrap();
let provider = mock("ok");
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["hello"], outputs.clone());
let (memory, cid) = make_memory_file(&provider, db_str).await;
let config = learning_config(true);
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
.with_learning(config);
agent.run().await.unwrap();
let store = SqliteStore::new(db_str).await.unwrap();
let stats = store.load_skill_outcome_stats().await.unwrap();
assert!(stats.is_empty());
}
}
mod trust_commands {
use std::sync::{Arc, Mutex};
use zeph_core::agent::Agent;
use zeph_llm::any::AnyProvider;
use zeph_llm::mock::MockProvider;
use zeph_memory::semantic::SemanticMemory;
use zeph_memory::store::SourceKind;
use zeph_memory::types::ConversationId;
use zeph_skills::registry::SkillRegistry;
use super::{MockChannel, MockToolExecutor};
fn mock_provider() -> AnyProvider {
let mut p = MockProvider::default();
p.default_response = String::new();
AnyProvider::Mock(p)
}
async fn make_memory(provider: &AnyProvider) -> (SemanticMemory, ConversationId) {
let memory = SemanticMemory::new(
":memory:",
"http://invalid:6334",
None,
provider.clone(),
"test",
)
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
(memory, cid)
}
#[tokio::test]
async fn trust_list_empty_db_shows_no_data() {
let provider = mock_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill trust"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected.iter().any(|o| o.contains("No skill trust data")),
"expected no-data message, got: {collected:?}"
);
}
#[tokio::test]
async fn trust_list_with_entries_shows_skills() {
let provider = mock_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill trust"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
memory
.sqlite()
.upsert_skill_trust(
"my-skill",
"trusted",
SourceKind::Local,
None,
None,
"deadbeef",
)
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected.iter().any(|o| o.contains("my-skill")),
"expected skill name in output, got: {collected:?}"
);
}
#[tokio::test]
async fn trust_set_valid_level_updates_skill() {
let provider = mock_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill trust my-skill blocked"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
memory
.sqlite()
.upsert_skill_trust(
"my-skill",
"trusted",
SourceKind::Local,
None,
None,
"deadbeef",
)
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected.iter().any(|o| o.contains("blocked")),
"expected updated level in output, got: {collected:?}"
);
}
#[tokio::test]
async fn trust_set_invalid_level_returns_error() {
let provider = mock_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill trust my-skill superadmin"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
memory
.sqlite()
.upsert_skill_trust(
"my-skill",
"trusted",
SourceKind::Local,
None,
None,
"deadbeef",
)
.await
.unwrap();
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected.iter().any(|o| o.contains("Invalid trust level")),
"expected invalid level error, got: {collected:?}"
);
}
#[tokio::test]
async fn trust_command_without_memory_reports_unavailable() {
let provider = mock_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill trust"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected.iter().any(|o| o.contains("Memory not available")),
"expected memory-unavailable message, got: {collected:?}"
);
}
#[tokio::test]
async fn trust_set_nonexistent_skill_reports_not_found() {
let provider = mock_provider();
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["/skill trust ghost trusted"], outputs.clone());
let (memory, cid) = make_memory(&provider).await;
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
5,
MockToolExecutor,
)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert!(
collected.iter().any(|o| o.contains("not found")),
"expected not-found message, got: {collected:?}"
);
}
}
mod e2e {
use std::io::Write as _;
use std::path::Path;
use std::sync::{Arc, Mutex};
use zeph_core::agent::Agent;
use zeph_core::config::Config;
use zeph_llm::any::AnyProvider;
use zeph_llm::mock::MockProvider;
use zeph_skills::registry::SkillRegistry;
use super::{MockChannel, MockToolExecutor};
#[tokio::test]
async fn e2e_config_toml_to_agent_single_loop() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("zeph.toml");
let mut f = std::fs::File::create(&config_path).unwrap();
write!(
f,
r#"
[agent]
name = "TestAgent"
[llm]
provider = "ollama"
model = "qwen3:8b"
base_url = "http://localhost:11434"
[skills]
paths = []
[memory]
sqlite_path = ".zeph/data/test.db"
history_limit = 10
"#
)
.unwrap();
let config = Config::load(Path::new(&config_path)).unwrap();
assert_eq!(config.agent.name, "TestAgent");
assert_eq!(config.memory.history_limit, 10);
let mut provider = MockProvider::default();
provider.default_response = "hello from agent".to_string();
let provider = AnyProvider::Mock(provider);
let outputs = Arc::new(Mutex::new(Vec::new()));
let channel = MockChannel::new(vec!["ping"], outputs.clone());
let mut agent = Agent::new(
provider,
channel,
SkillRegistry::default(),
None,
usize::try_from(config.memory.history_limit).unwrap(),
MockToolExecutor,
);
agent.run().await.unwrap();
let collected = outputs.lock().unwrap();
assert_eq!(collected.len(), 1);
assert_eq!(collected[0], "hello from agent");
}
}