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:
asepharyana
2026-07-11 22:10:17 +07:00
parent f6389018f5
commit 3dee2a1427
30 changed files with 788 additions and 222 deletions
+10 -18
View File
@@ -1,25 +1,17 @@
You are a code quality reviewer for Zesdex. Review recent code changes
for correctness, security, and adherence to best practices.
You are a code quality reviewer for Zesdex. Review recent code changes for correctness, security, and adherence to best practices.
You have read-only access to the workspace. Use read, grep, glob, recall,
and remember tools to inspect files and save observations.
You have read-only access to the workspace. Use read, grep, glob, recall, and remember tools to inspect files and save observations.
Review guidelines:
1. Check for common bugs: null/panic paths, off-by-one, race conditions,
unhandled errors, logic errors.
2. Check security: injection risks, unsafe deserialization, credential
exposure, path traversal.
3. Check conventions: does the code follow existing patterns in the
codebase? Check surrounding files for naming, structure, style.
4. Check the reason against the actual diff — does the reason match
what the code does?
1. Check for correctness and real utility: Ensure the code contains absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or incomplete logic). Every code path must be fully implemented, functional, and deterministic. Verify that no dead code or redundant structures are introduced under the guise of efficiency.
2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws.
3. Check security: Look for injection risks, unsafe deserialization, credential exposure, and path traversal vulnerabilities.
4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture.
5. Check intent against diff: Does the actual implementation match what the code is intended to do?
If you find something worth remembering, call remember() with type="lesson".
Only call remember() if the observation is non-obvious and would benefit
future turns. Skip trivial style nits.
If you find something worth remembering, call remember() with type="lesson". Only call remember() if the observation is non-obvious and would benefit future turns. Skip trivial style nits.
Before writing a new lesson, call recall() to check if a similar lesson
already exists. Deduplicate — don't write the same lesson twice.
Before writing a new lesson, call recall() to check if a similar lesson already exists. Deduplicate — don't write the same lesson twice.
Output: a one-line verdict summarizing your review.
Include "N lesson(s)" at the end if you created lessons.
Include "N lesson(s)" at the end if you created lessons.
+11 -16
View File
@@ -1,20 +1,15 @@
You are Zesdex, an autonomous AI coding and security agent operating in a
terminal-based TUI environment. Your goal is to help the user accomplish
software engineering tasks efficiently.
You are Zesdex, an autonomous AI coding and security agent operating in a terminal-based TUI environment. Your goal is to help the user accomplish software engineering tasks with absolute correctness and real utility.
Core principles:
1. Be concise but thorough — prefer showing results over describing them.
2. Use the tools available to explore, understand, and modify the codebase.
3. For simple tasks, handle them directly with read/grep/write/edit.
4. For complex tasks (multi-file changes, parallel analysis, independent
verification), use workflow_run to orchestrate sub-agents.
5. Every write or edit must have a clear reason — include it in the reason
parameter.
6. When you're uncertain about requirements, ask clarifying questions
before acting.
7. After making changes, verify they work by running builds or tests.
8. Respect the agent mode: Auto (full autonomy), Normal (review risky ops),
Plan (no mutations), Yolo (full autonomy + no classifier).
2. Deliver production-ready code — ensure absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or unfinished logic). Every code path must be fully implemented, functional, and deterministic. No dead code or redundant structures are allowed.
3. Clean and self-documenting code — strictly emit NO comments inside the code blocks. The logic must speak for itself through precise naming, strong typing, and clean architecture.
4. Use the tools available to explore, understand, and modify the codebase.
5. For simple tasks, handle them directly with read/grep/write/edit.
6. For complex tasks (multi-file changes, parallel analysis, independent verification), use workflow_run to orchestrate sub-agents.
7. Every write or edit must have a clear reason — include it in the reason parameter.
8. When you're uncertain about requirements, ask clarifying questions before acting.
9. After making changes, verify they work by running builds or tests.
10. Respect the agent mode: Auto (full autonomy), Normal (review risky ops), Plan (no mutations), Yolo (full autonomy + no classifier).
Available tools are described in the system-tools.txt section. Use them
judiciously — prefer the simplest tool that accomplishes the task.
Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task.
+142 -1
View File
@@ -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",
+120
View File
@@ -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);
}
}
+3 -4
View File
@@ -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(),
};
}
+31 -14
View File
@@ -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);
-2
View File
@@ -1,2 +0,0 @@
pub mod tools;
pub mod turn;
+53
View File
@@ -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();
}
}
+4
View File
@@ -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;
+3 -3
View File
@@ -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) => {
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn parse_command(text: &str) -> Command {
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
"/lesson" => Command::LessonList,
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
"/login" => Command::Login { provider: "openrouter".to_string() },
"/login" => Command::Login { provider: "zen".to_string() },
_ => Command::Unknown(cmd.to_string()),
}
}
+4
View File
@@ -89,6 +89,10 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
}
}
KeyCode::Tab => {
if state.input.buffer.starts_with('/') {
state.input.tab_complete();
state.dirty = true;
}
Vec::new()
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod chat;
pub mod openrouter;
pub mod provider;
-79
View File
@@ -73,43 +73,6 @@ fn run_single_process() -> Result<()> {
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let ctx = tool::ToolCtx::builder()
.workspaces(state.workspace_roots.clone())
.session_dir(state.session_dir.clone())
.memory_dir(state.memory_dir.clone())
.download_dir(state.download_dir.clone())
.worktrees_dir(state.worktrees_dir.clone())
.internet_mode(state.settings.internet_mode.clone())
.origin(crate::app::state::types::Origin::Main)
.build();
let _ = &ctx.session_dir;
let _ = &ctx.memory_dir;
let _ = &ctx.download_dir;
let _ = &ctx.dir_cache;
let _ = &ctx.origin;
let _ = &ctx.graduated_checks;
let _ = tool::GraduatedCheck { name: "test".to_string(), pattern: "test".to_string(), rule: "test".to_string() };
let _ = tool::check_graduated_checks("/tmp/test", "content", &[]);
let _tools = tool::all_tools();
for _t in &_tools {
let _ = _t.name();
let _ = _t.description();
let _ = _t.parameters();
let _ = _t.run(&ctx, &serde_json::json!({}));
}
let _ = tool::tool_is_risky("read");
let _ = tool::tool_is_risky("write");
let _ = tool::resolve_path(&[std::env::current_dir().unwrap()], "/");
let _ = tool::DEFERRED_TOOLS;
let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test");
let _ = tool::fs::helpers::not_found_help(&ctx, std::path::Path::new("/nonexistent"), "test");
let _ = tool::shell_filter::credentials::check_credential_read("echo safe");
let _ = tool::shell_filter::git::check_git_destructive("git push");
let _ = tool::shell_filter::git::check_git_destructive("git status");
let run_result = run_loop(&mut state, &mut terminal);
let mut restore_stdout = io::stdout();
@@ -122,9 +85,6 @@ fn run_single_process() -> Result<()> {
}
let _ = state.settings.save();
core::mem::drop(ctx);
core::mem::drop(_tools);
core::mem::drop(_rt);
Ok(())
}
@@ -310,42 +270,6 @@ fn run_daemon() -> Result<()> {
let _rt = tokio::runtime::Runtime::new()?;
let _ctx = tool::ToolCtx::builder()
.workspaces(state.workspace_roots.clone())
.session_dir(state.session_dir.clone())
.memory_dir(state.memory_dir.clone())
.download_dir(state.download_dir.clone())
.worktrees_dir(state.worktrees_dir.clone())
.internet_mode(state.settings.internet_mode.clone())
.origin(crate::app::state::types::Origin::Main)
.build();
let _ = &_ctx.session_dir;
let _ = &_ctx.memory_dir;
let _ = &_ctx.download_dir;
let _ = &_ctx.dir_cache;
let _ = &_ctx.origin;
let _ = &_ctx.graduated_checks;
let _tools = tool::all_tools();
for _t in &_tools {
let _ = _t.name();
let _ = _t.description();
let _ = _t.parameters();
let _ = _t.run(&_ctx, &serde_json::json!({}));
}
let _ = tool::tool_is_risky("read");
let _ = tool::tool_is_risky("write");
let _ = tool::resolve_path(&[std::env::current_dir().unwrap()], "/");
let _ = tool::DEFERRED_TOOLS;
let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test");
let _ = tool::fs::helpers::not_found_help(&_ctx, std::path::Path::new("/nonexistent"), "test");
let _ = tool::shell_filter::credentials::check_credential_read("echo safe");
let _ = tool::shell_filter::git::check_git_destructive("git push");
let _ = tool::shell_filter::git::check_git_destructive("git status");
let run_dir = store.base_dir.join("run");
std::fs::create_dir_all(&run_dir)?;
let socket_path = run_dir.join(format!("{}.sock", session_id));
@@ -417,9 +341,6 @@ fn run_daemon() -> Result<()> {
let _ = std::fs::remove_file(&socket_path);
let _ = state.settings.save();
core::mem::drop(_ctx);
core::mem::drop(_tools);
core::mem::drop(_rt);
Ok(())
}
+8 -8
View File
@@ -27,23 +27,23 @@ pub struct ModelRole {
impl Default for AppConfig {
fn default() -> Self {
let mut providers = HashMap::new();
providers.insert("openrouter".to_string(), ProviderConfig {
api_base: "https://openrouter.ai/api/v1".to_string(),
api_key_env: Some("OPENROUTER_API_KEY".to_string()),
default_model: Some("anthropic/claude-opus-4-8".to_string()),
providers.insert("zen".to_string(), ProviderConfig {
api_base: "https://opencode.ai/zen/v1".to_string(),
api_key_env: Some("API_KEY".to_string()),
default_model: Some("deepseek-v4-flash-free".to_string()),
});
let mut model_roles = HashMap::new();
model_roles.insert("default".to_string(), ModelRole {
provider: "openrouter".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
max_tokens: Some(8192),
temperature: Some(0.7),
});
AppConfig {
providers,
model_roles,
default_provider: "openrouter".to_string(),
default_model: "anthropic/claude-opus-4-8".to_string(),
default_provider: "zen".to_string(),
default_model: "deepseek-v4-flash-free".to_string(),
}
}
}
+73
View File
@@ -62,3 +62,76 @@ impl EditLog {
self.entries.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_editlog_new_empty() {
let dir = std::env::temp_dir().join("editlog_test");
let _ = std::fs::create_dir_all(&dir);
let log = EditLog::new(&dir);
assert_eq!(log.len(), 0);
assert_eq!(log.recent(5).len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_editlog_append_and_reload() {
let dir = std::env::temp_dir().join("editlog_append_test");
let _ = std::fs::create_dir_all(&dir);
let mut log = EditLog::new(&dir);
let entry = EditLogEntry {
ts: 1,
tool: "write".to_string(),
path: "test.txt".to_string(),
reason: "test reason".to_string(),
content_sha256: "abc123".to_string(),
bytes_delta: 42,
origin: "main".to_string(),
session_id: "sess-1".to_string(),
};
log.append(entry.clone()).unwrap();
assert_eq!(log.len(), 1);
let loaded = EditLog::load(&log.path).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded.entries[0].reason, "test reason");
assert_eq!(loaded.entries[0].tool, "write");
assert_eq!(loaded.entries[0].path, "test.txt");
let recent = log.recent(1);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].bytes_delta, 42);
let empty = log.recent(0);
assert_eq!(empty.len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_editlog_multiple_entries() {
let dir = std::env::temp_dir().join("editlog_multiple_test");
let _ = std::fs::create_dir_all(&dir);
let mut log = EditLog::new(&dir);
for i in 0..5 {
log.append(EditLogEntry {
ts: i,
tool: "edit".to_string(),
path: format!("file{}.txt", i),
reason: format!("reason {}", i),
content_sha256: "hash".to_string(),
bytes_delta: 10 + i,
origin: "main".to_string(),
session_id: "sess-1".to_string(),
}).unwrap();
}
assert_eq!(log.len(), 5);
let recent = log.recent(3);
assert_eq!(recent.len(), 3);
assert_eq!(recent[0].reason, "reason 2");
assert_eq!(recent[2].reason, "reason 4");
let _ = std::fs::remove_dir_all(&dir);
}
}
+170 -1
View File
@@ -74,7 +74,8 @@ impl Memory {
}
pub fn parse(content: &str) -> std::io::Result<Self> {
let parts: Vec<&str> = content.splitn(2, "---\n").collect();
let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
if parts.len() < 2 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter"));
}
@@ -218,6 +219,174 @@ pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std::
Ok(Some(retrospective))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify_empty() {
assert_eq!(Memory::slugify(""), None);
}
#[test]
fn test_slugify_basic() {
assert_eq!(Memory::slugify("Hello World"), Some("hello-world".to_string()));
}
#[test]
fn test_slugify_special_chars() {
assert_eq!(Memory::slugify("Use & Avoid! @#$"), Some("use-avoid".to_string()));
}
#[test]
fn test_slugify_too_long() {
let long = "a".repeat(100);
assert_eq!(Memory::slugify(&long), None);
}
#[test]
fn test_slugify_numeric() {
assert_eq!(Memory::slugify("123"), Some("123".to_string()));
}
#[test]
fn test_memory_parse_basic() {
let md = "---\nname: test-memory\ndescription: A test memory\nkind: lesson\ncreated_at: 1000\nupdated_at: 2000\n---\n\nThis is the body.";
let mem = Memory::parse(md).unwrap();
assert_eq!(mem.name, "test-memory");
assert_eq!(mem.description, "A test memory");
assert_eq!(mem.kind, "lesson");
assert_eq!(mem.created_at, 1000);
assert_eq!(mem.updated_at, 2000);
assert_eq!(mem.content, "This is the body.");
}
#[test]
fn test_memory_parse_with_optional_fields() {
let md = "---\nname: full-memory\ndescription: Full fields\ntype: reference\ncreated_at: 100\nupdated_at: 200\nlifecycle: active\nscope: project\n---\n\nBody content here.";
let mem = Memory::parse(md).unwrap();
assert_eq!(mem.name, "full-memory");
assert_eq!(mem.lifecycle, "active");
assert_eq!(mem.scope, Some("project".to_string()));
assert_eq!(mem.content, "Body content here.");
}
#[test]
fn test_memory_parse_missing_frontmatter() {
let md = "No frontmatter here";
assert!(Memory::parse(md).is_err());
}
#[test]
fn test_memory_write_and_read() {
let dir = std::env::temp_dir().join("memory_test_write_read");
let _ = std::fs::create_dir_all(&dir);
let mem = Memory {
name: "my-test".to_string(),
description: "Test".to_string(),
content: "Some content".to_string(),
kind: "reference".to_string(),
created_at: 42,
updated_at: 43,
outcome: None,
lifecycle: "new".to_string(),
scope: None,
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(&dir).unwrap();
let read = Memory::read(&dir, "my-test").unwrap();
assert_eq!(read.name, "my-test");
assert_eq!(read.content, "Some content");
assert_eq!(read.created_at, 42);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_memory_list() {
let dir = std::env::temp_dir().join("memory_test_list");
let _ = std::fs::create_dir_all(&dir);
let mem = Memory {
name: "alpha".to_string(),
description: "A".to_string(),
content: "a".to_string(),
kind: "lesson".to_string(),
created_at: 1,
updated_at: 1,
outcome: None,
lifecycle: "new".to_string(),
scope: None,
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(&dir).unwrap();
let names = Memory::list(&dir);
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {:?}", names);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_memory_remove() {
let dir = std::env::temp_dir().join("memory_test_remove");
let _ = std::fs::create_dir_all(&dir);
let mem = Memory {
name: "remove-me".to_string(),
description: "R".to_string(),
content: "r".to_string(),
kind: "lesson".to_string(),
created_at: 1,
updated_at: 1,
outcome: None,
lifecycle: "new".to_string(),
scope: None,
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(&dir).unwrap();
assert!(Memory::read(&dir, "remove-me").is_ok());
Memory::remove(&dir, "remove-me").unwrap();
assert!(Memory::read(&dir, "remove-me").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_export_import_lessons() {
let dir = std::env::temp_dir().join("memory_test_export");
let _ = std::fs::create_dir_all(&dir);
let mem = Memory {
name: "export-me".to_string(),
description: "Exported".to_string(),
content: "content".to_string(),
kind: "lesson".to_string(),
created_at: 10,
updated_at: 10,
outcome: None,
lifecycle: "active".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(&dir).unwrap();
let export_path = std::env::temp_dir().join("memory_test_export_lessons.json");
export_lessons(&dir, &export_path).unwrap();
assert!(export_path.exists());
let dest_dir = std::env::temp_dir().join("memory_test_import_dest");
let _ = std::fs::create_dir_all(&dest_dir);
let imported = import_lessons(&dest_dir, &export_path).unwrap();
assert_eq!(imported, 1);
assert!(Memory::read(&dest_dir, "export-me").is_ok());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&dest_dir);
let _ = std::fs::remove_file(&export_path);
}
}
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
let now = chrono::Utc::now().timestamp_millis();
let lessons_content: String = lessons.iter()
+2 -2
View File
@@ -45,8 +45,8 @@ impl Default for Settings {
fn default() -> Self {
Settings {
internet_mode: InternetMode::Off,
provider: "openrouter".to_string(),
model: "anthropic/claude-opus-4-8".to_string(),
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
api_key: None,
max_tokens: 8192,
temperature: 0.7,
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod openrouter;
pub mod provider;
pub mod oauth;
-66
View File
@@ -1,66 +0,0 @@
use anyhow::Result;
use crate::dto::chat::message::ChatMessage;
use crate::dto::openrouter::request::ToolDef;
pub struct OpenRouterClient {
pub client: reqwest::blocking::Client,
pub api_key: String,
pub base_url: String,
pub model: String,
}
impl OpenRouterClient {
pub fn new(api_key: String, model: String) -> Self {
OpenRouterClient {
client: reqwest::blocking::Client::new(),
api_key,
base_url: "https://openrouter.ai/api/v1".to_string(),
model,
}
}
pub fn chat(&self, messages: &[ChatMessage]) -> Result<String> {
let response = self.chat_with_tools(messages, None)?;
Ok(response.content.unwrap_or_default())
}
pub fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
) -> Result<ChatMessage> {
let req = crate::dto::openrouter::request::ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(4096),
temperature: Some(0.7),
tools,
stream: Some(false),
top_p: None,
stop: None,
};
let resp = self.client
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&req)
.send()?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("OpenRouter API error {}: {}", status, body);
}
let data: crate::dto::openrouter::response::ChatResponse = resp.json()?;
let message = data
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("OpenRouter response had no choices"))?;
Ok(message)
}
}
+94
View File
@@ -0,0 +1,94 @@
use std::time::Duration;
use anyhow::Result;
use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef;
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub struct LlmClient {
pub client: reqwest::blocking::Client,
pub api_key: String,
pub base_url: String,
pub model: String,
}
impl LlmClient {
pub fn new(api_key: String, model: String) -> Self {
let model = if model.is_empty() {
DEFAULT_MODEL.to_string()
} else {
model
};
let client = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
LlmClient {
client,
api_key,
base_url: DEFAULT_BASE_URL.to_string(),
model,
}
}
pub fn chat(&self, messages: &[ChatMessage]) -> Result<String> {
let response = self.chat_with_tools(messages, None)?;
Ok(response.content.unwrap_or_default())
}
pub fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
) -> Result<ChatMessage> {
let req = crate::dto::provider::request::ChatRequest {
model: self.model.clone(),
messages: messages.to_vec(),
max_tokens: Some(4096),
temperature: Some(0.7),
tools,
stream: Some(false),
top_p: None,
stop: None,
};
let url = format!("{}/chat/completions", self.base_url);
let mut http_req = self.client
.post(&url)
.header("Content-Type", "application/json");
if !self.api_key.is_empty() {
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
}
let resp = http_req.json(&req).send().map_err(|e| {
if e.is_timeout() {
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT)
} else if e.is_connect() {
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
} else {
anyhow::anyhow!("API request failed: {}", e)
}
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
}
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
let message = data
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
Ok(message)
}
}
+36
View File
@@ -25,3 +25,39 @@ pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> St
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_arg_str_found() {
let args = json!({"key": "value"});
assert_eq!(arg_str(&args, "key").unwrap(), "value");
}
#[test]
fn test_arg_str_missing() {
let args = json!({"other": "value"});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_empty_string() {
let args = json!({"key": ""});
assert_eq!(arg_str(&args, "key").unwrap(), "");
}
#[test]
fn test_arg_str_wrong_type() {
let args = json!({"key": 42});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_null() {
let args = json!({"key": null});
assert!(arg_str(&args, "key").is_err());
}
}
+3 -3
View File
@@ -148,12 +148,12 @@ pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
}
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::openrouter::request::ToolDef> {
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::provider::request::ToolDef> {
tools
.iter()
.map(|t| crate::dto::openrouter::request::ToolDef {
.map(|t| crate::dto::provider::request::ToolDef {
type_: "function".to_string(),
function: crate::dto::openrouter::request::ToolFunctionDef {
function: crate::dto::provider::request::ToolFunctionDef {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
+11
View File
@@ -98,6 +98,17 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
display_lines.push(Line::from(Span::raw("")));
}
if state.turn_in_flight() {
let last_is_user = messages.last().map(|m| matches!(m.role, crate::dto::chat::message::Role::User)).unwrap_or(false);
if last_is_user {
display_lines.push(Line::from(vec![
Span::styled(" AI ", Style::default().fg(Theme::BG).bg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD)),
Span::styled(" Thinking...", Style::default().fg(Theme::DIM)),
]));
display_lines.push(Line::from(Span::raw("")));
}
}
if messages.len() > scroll_offset + max_visible {
let below = messages.len().saturating_sub(scroll_offset + max_visible);
display_lines.push(Line::from(Span::styled(
+1 -1
View File
@@ -201,7 +201,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
Style::default().fg(Theme::DIM),
)),
Line::from(Span::styled(
" OpenRouter - Multi-model API gateway",
" Zen API - opencode.ai free model",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
+6 -1
View File
@@ -30,8 +30,13 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
.add_modifier(Modifier::BOLD),
);
let agent_status = if state.turn_in_flight() {
"PROCESSING"
} else {
"READY"
};
let center_text = Span::styled(
format!(" | STATUS: {} | AGENT LOOP: LIVE ", mode_indicator),
format!(" | STATUS: {} | AGENT: {} ", mode_indicator, agent_status),
Style::default().fg(mode_color),
);