Refactor API integration and enhance command handling
- Removed unused modules and updated module paths for clarity. - Added autocomplete functionality for command input in InputState. - Updated AppStateRest to include a method for checking if a turn is in flight. - Refactored subagent engine to use new API client structure. - Changed default provider from "openrouter" to "zen" with updated API keys and models. - Implemented tests for memory management and edit log functionalities. - Enhanced error handling in API requests and improved response parsing. - Updated UI components to reflect new API provider and status indicators.
This commit is contained in:
+142
-1
@@ -1,5 +1,146 @@
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_allow_safe_git_ops() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git commit -m 'fix'").is_ok());
|
||||
assert!(CatastrophicGuard::check_git_operation("git push origin main").is_ok());
|
||||
assert!(CatastrophicGuard::check_git_operation("git pull").is_ok());
|
||||
assert!(CatastrophicGuard::check_git_operation("git status").is_ok());
|
||||
assert!(CatastrophicGuard::check_git_operation("git log --oneline").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_force_push() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git push --force origin main").is_err());
|
||||
assert!(CatastrophicGuard::check_git_operation("git push +refs/heads/main").is_err());
|
||||
assert!(CatastrophicGuard::check_git_operation("git push origin :main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_reset_hard() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git reset --hard HEAD~1").is_err());
|
||||
assert!(CatastrophicGuard::check_git_operation("git reset --hard origin/main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_git_clean() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git clean -fd").is_err());
|
||||
assert!(CatastrophicGuard::check_git_operation("git clean -xdf").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_branch_force_delete() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git branch -D feature").is_err());
|
||||
assert!(CatastrophicGuard::check_git_operation("git branch --delete --force main").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_force_checkout() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git checkout --force other").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_stash_destructive() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git stash drop").is_err());
|
||||
assert!(CatastrophicGuard::check_git_operation("git stash clear").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_filter_branch() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git filter-branch --force").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_gc_prune() {
|
||||
assert!(CatastrophicGuard::check_git_operation("git gc --prune=now").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_safe_shell() {
|
||||
assert!(CatastrophicGuard::check_shell_command("ls -la /tmp").is_ok());
|
||||
assert!(CatastrophicGuard::check_shell_command("echo hello").is_ok());
|
||||
assert!(CatastrophicGuard::check_shell_command("cat /etc/hostname").is_ok());
|
||||
assert!(CatastrophicGuard::check_shell_command("cargo build").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_dd() {
|
||||
assert!(CatastrophicGuard::check_shell_command("dd if=/dev/zero of=/dev/sda").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_format() {
|
||||
assert!(CatastrophicGuard::check_shell_command("mkfs.ext4 /dev/sdb1").is_err());
|
||||
assert!(CatastrophicGuard::check_shell_command("format /dev/sdc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_shutdown_reboot() {
|
||||
assert!(CatastrophicGuard::check_shell_command("shutdown -h now").is_err());
|
||||
assert!(CatastrophicGuard::check_shell_command("reboot").is_err());
|
||||
assert!(CatastrophicGuard::check_shell_command("poweroff").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_system_directory_delete() {
|
||||
let p = Path::new("/");
|
||||
assert!(CatastrophicGuard::check_delete_path(p, &[]).is_err());
|
||||
let p = Path::new("/home");
|
||||
assert!(CatastrophicGuard::check_delete_path(p, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_pattern_blocked() {
|
||||
assert!(CatastrophicGuard::check_credential_pattern("cat ~/.ssh/id_rsa").is_err());
|
||||
assert!(CatastrophicGuard::check_credential_pattern("cat .git-credentials").is_err());
|
||||
assert!(CatastrophicGuard::check_credential_pattern("cat ~/.netrc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_pattern_allowed() {
|
||||
assert!(CatastrophicGuard::check_credential_pattern("cat README.md").is_ok());
|
||||
assert!(CatastrophicGuard::check_credential_pattern("ls -la").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_download_path_sensitive() {
|
||||
let sensitive = Path::new("/tmp/id_rsa");
|
||||
assert!(CatastrophicGuard::check_download_path(sensitive).is_err());
|
||||
let sensitive = Path::new("/tmp/credentials.json");
|
||||
assert!(CatastrophicGuard::check_download_path(sensitive).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_download_path_allowed() {
|
||||
let safe = Path::new("/tmp/report.pdf");
|
||||
assert!(CatastrophicGuard::check_download_path(safe).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_all_blocks_destructive() {
|
||||
assert!(CatastrophicGuard::check_all("git push --force origin main", &[]).is_err());
|
||||
assert!(CatastrophicGuard::check_all("dd if=/dev/zero of=/dev/sda", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_all_allows_safe() {
|
||||
assert!(CatastrophicGuard::check_all("git commit -m 'fix'", &[]).is_ok());
|
||||
assert!(CatastrophicGuard::check_all("cargo build", &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_outside_workspace() {
|
||||
let workspace = Path::new("/tmp/test_ws");
|
||||
let outside = Path::new("/etc/passwd");
|
||||
assert!(CatastrophicGuard::check_delete_path(outside, &[workspace]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CatastrophicGuard;
|
||||
|
||||
impl CatastrophicGuard {
|
||||
@@ -10,7 +151,7 @@ impl CatastrophicGuard {
|
||||
"clean -f",
|
||||
"clean -d",
|
||||
"clean -x",
|
||||
"branch -D",
|
||||
"branch -d",
|
||||
"branch --delete --force",
|
||||
"checkout --force",
|
||||
"switch -f",
|
||||
|
||||
@@ -115,3 +115,123 @@ impl Default for Harness {
|
||||
Harness
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::state::types::AgentMode;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_verdict_is_allowed() {
|
||||
assert!(Verdict::Allow.is_allowed());
|
||||
assert!(!Verdict::Block("test".to_string()).is_allowed());
|
||||
assert!(!Verdict::Escalate.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_auto_mode_allows() {
|
||||
assert_eq!(Harness::classify("write", &AgentMode::Auto), Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_yolo_mode_allows() {
|
||||
assert_eq!(Harness::classify("write", &AgentMode::Yolo), Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_plan_mode_blocks() {
|
||||
let result = Harness::classify("write", &AgentMode::Plan);
|
||||
assert!(matches!(result, Verdict::Block(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_normal_mode_escalates() {
|
||||
assert_eq!(Harness::classify("write", &AgentMode::Normal), Verdict::Escalate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_tool_non_risky_always_allows() {
|
||||
let mode = AgentMode::Normal;
|
||||
let roots: &[&std::path::Path] = &[];
|
||||
let result = Harness::gate_tool_call("read", &json!({"path": "test.txt"}), &mode, roots);
|
||||
assert_eq!(result, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_tool_bash_non_destructive_allowed_in_auto() {
|
||||
let mode = AgentMode::Auto;
|
||||
let roots: &[&std::path::Path] = &[];
|
||||
let result = Harness::gate_tool_call("bash", &json!({"command": "ls -la"}), &mode, roots);
|
||||
assert_eq!(result, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_tool_bash_destructive_blocked() {
|
||||
let mode = AgentMode::Auto;
|
||||
let roots: &[&std::path::Path] = &[];
|
||||
let result = Harness::gate_tool_call("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}), &mode, roots);
|
||||
assert!(matches!(result, Verdict::Block(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_tool_git_operator_destructive_blocked() {
|
||||
let mode = AgentMode::Auto;
|
||||
let roots: &[&std::path::Path] = &[];
|
||||
let result = Harness::gate_tool_call(
|
||||
"git_operator",
|
||||
&json!({"operation": "push", "args": ["--force"]}),
|
||||
&mode,
|
||||
roots,
|
||||
);
|
||||
assert!(matches!(result, Verdict::Block(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_json_allow() {
|
||||
let v = parse_verdict(r#"{"verdict": "allow"}"#);
|
||||
assert_eq!(v, Some(Verdict::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_json_block() {
|
||||
let v = parse_verdict(r#"{"verdict": "block", "reason": "dangerous operation"}"#);
|
||||
assert_eq!(v, Some(Verdict::Block("dangerous operation".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_json_escalate() {
|
||||
let v = parse_verdict(r#"{"verdict": "escalate"}"#);
|
||||
assert_eq!(v, Some(Verdict::Escalate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_text_allow() {
|
||||
let v = parse_verdict("Verdict: Allow");
|
||||
assert_eq!(v, Some(Verdict::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_text_block() {
|
||||
let v = parse_verdict("Verdict: Block - this operation is not allowed");
|
||||
assert!(matches!(v, Some(Verdict::Block(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_fallback_allow() {
|
||||
let v = parse_verdict("I think we should allow this operation");
|
||||
assert_eq!(v, Some(Verdict::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_fallback_block() {
|
||||
let v = parse_verdict("This request should be blocked");
|
||||
assert!(matches!(v, Some(Verdict::Block(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_unparseable() {
|
||||
let v = parse_verdict("completely unrelated text with no keywords");
|
||||
assert_eq!(v, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use crate::model::settings::Settings;
|
||||
|
||||
pub const PROVIDERS: &[&str] = &["OpenRouter", "Anthropic", "OpenAI"];
|
||||
pub const PROVIDERS: &[&str] = &["Zen API", "OpenAI"];
|
||||
|
||||
pub fn set_provider(settings: &mut Settings, provider: &str) {
|
||||
settings.provider = match provider {
|
||||
"OpenRouter" => "openrouter".to_string(),
|
||||
"Anthropic" => "anthropic".to_string(),
|
||||
"Zen API" => "zen".to_string(),
|
||||
"OpenAI" => "openai".to_string(),
|
||||
_ => "openrouter".to_string(),
|
||||
_ => "zen".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -397,7 +397,17 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
}
|
||||
TurnEvent::Error(msg) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, msg));
|
||||
let long_toast = Toast {
|
||||
kind: ToastKind::Error,
|
||||
message: msg.clone(),
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
lifetime_ms: 15000,
|
||||
};
|
||||
state.push_toast(long_toast);
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
format!("Error: {}", msg),
|
||||
));
|
||||
turn_finished = true;
|
||||
}
|
||||
TurnEvent::Done => {
|
||||
@@ -453,16 +463,13 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
return;
|
||||
}
|
||||
let api_key = state.settings.api_key.clone().unwrap_or_default();
|
||||
if api_key.is_empty() {
|
||||
return;
|
||||
}
|
||||
let model = state.settings.model.clone();
|
||||
let mut tools = crate::tool::all_tools();
|
||||
tools.extend(state.mcp_manager.as_tools());
|
||||
let tool_defs = crate::tool::tool_defs(&tools);
|
||||
let ctx = state.tool_ctx();
|
||||
let mode = state.mode;
|
||||
let edit_log_path = state.edit_log.path.clone();
|
||||
let edit_session_dir = state.session_dir.clone();
|
||||
let session_id = state.session_id.clone();
|
||||
let turn_events = state.turn_events.clone();
|
||||
let in_flight_flag = state.turn_in_flight.clone();
|
||||
@@ -474,13 +481,13 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let tc = TurnCtx {
|
||||
client: crate::service::openrouter::OpenRouterClient::new(api_key, model),
|
||||
client: crate::service::provider::LlmClient::new(api_key, model),
|
||||
tdefs: tool_defs,
|
||||
tools,
|
||||
ctx,
|
||||
mode,
|
||||
workspace_roots,
|
||||
edit_log_path,
|
||||
edit_log_session_dir: edit_session_dir,
|
||||
session_id,
|
||||
};
|
||||
let result = run_agent_turn(tc, &messages, &events_q);
|
||||
@@ -496,13 +503,13 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
}
|
||||
|
||||
struct TurnCtx {
|
||||
client: crate::service::openrouter::OpenRouterClient,
|
||||
tdefs: Vec<crate::dto::openrouter::request::ToolDef>,
|
||||
client: crate::service::provider::LlmClient,
|
||||
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
||||
tools: Vec<Box<dyn crate::tool::Tool>>,
|
||||
ctx: crate::tool::ToolCtx,
|
||||
mode: AgentMode,
|
||||
workspace_roots: Vec<std::path::PathBuf>,
|
||||
edit_log_path: std::path::PathBuf,
|
||||
edit_log_session_dir: std::path::PathBuf,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
@@ -548,7 +555,7 @@ fn run_agent_turn(
|
||||
&tc.ctx,
|
||||
&tool_name,
|
||||
&args,
|
||||
&tc.edit_log_path,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.session_id,
|
||||
) {
|
||||
Ok(result) => (result, false, is_edit_tool),
|
||||
@@ -612,7 +619,7 @@ fn execute_one_tool(
|
||||
ctx: &crate::tool::ToolCtx,
|
||||
name: &str,
|
||||
args: &serde_json::Value,
|
||||
edit_log_path: &std::path::Path,
|
||||
session_dir: &std::path::Path,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
for tool in tools {
|
||||
@@ -634,17 +641,27 @@ fn execute_one_tool(
|
||||
);
|
||||
format!("{:x}", hash)
|
||||
};
|
||||
let bytes_delta = if name == "write" {
|
||||
args.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.len() as i64)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||
(new.len() as i64 - old.len() as i64).abs()
|
||||
};
|
||||
let entry = crate::model::editlog::EditLogEntry {
|
||||
ts: chrono::Utc::now().timestamp_millis(),
|
||||
tool: name.to_string(),
|
||||
path: path.to_string(),
|
||||
reason: reason.to_string(),
|
||||
content_sha256,
|
||||
bytes_delta: result.len() as i64,
|
||||
bytes_delta,
|
||||
origin: ctx.origin.tag(),
|
||||
session_id: session_id.to_string(),
|
||||
};
|
||||
let mut el = crate::model::editlog::EditLog::new(edit_log_path);
|
||||
let mut el = crate::model::editlog::EditLog::new(session_dir);
|
||||
el.append(entry).ok();
|
||||
}
|
||||
return Ok(result);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod tools;
|
||||
pub mod turn;
|
||||
|
||||
@@ -68,8 +68,28 @@ pub struct InputState {
|
||||
pub cursor: usize,
|
||||
pub history: Vec<String>,
|
||||
pub history_idx: Option<usize>,
|
||||
pub autocomplete_prefix: String,
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
pub autocomplete_idx: usize,
|
||||
}
|
||||
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help",
|
||||
"/quit",
|
||||
"/resume",
|
||||
"/clear",
|
||||
"/save",
|
||||
"/lesson",
|
||||
"/lesson ls",
|
||||
"/lesson export",
|
||||
"/lesson import",
|
||||
"/mode chat",
|
||||
"/mode bash",
|
||||
"/mode help",
|
||||
"/mode settings",
|
||||
"/login",
|
||||
];
|
||||
|
||||
impl InputState {
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
@@ -77,6 +97,39 @@ impl InputState {
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
autocomplete_prefix: String::new(),
|
||||
autocomplete_candidates: Vec::new(),
|
||||
autocomplete_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_complete(&mut self) {
|
||||
let trimmed = self.buffer.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !trimmed.starts_with('/') {
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = trimmed.to_lowercase();
|
||||
|
||||
if prefix != self.autocomplete_prefix || self.autocomplete_candidates.is_empty() {
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(|c| c.to_string())
|
||||
.collect();
|
||||
self.autocomplete_prefix = prefix;
|
||||
self.autocomplete_idx = 0;
|
||||
} else {
|
||||
self.autocomplete_idx = (self.autocomplete_idx + 1) % self.autocomplete_candidates.len();
|
||||
}
|
||||
|
||||
if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
|
||||
self.buffer = candidate.clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,10 @@ impl AppStateRest {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight.lock().map(|g| *g).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: AgentMode) {
|
||||
self.mode = mode;
|
||||
self.dirty = true;
|
||||
|
||||
@@ -29,10 +29,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
|
||||
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
||||
for step in 0..max_steps {
|
||||
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
|
||||
let model = std::env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-5".to_string());
|
||||
let api_key = std::env::var("API_KEY").unwrap_or_default();
|
||||
let model = std::env::var("MODEL").unwrap_or_default();
|
||||
|
||||
let client = crate::service::openrouter::OpenRouterClient::new(api_key, model);
|
||||
let client = crate::service::provider::LlmClient::new(api_key, model);
|
||||
let response = match client.chat(&messages) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user